From 77371c677a97d196e3fbe69a719ebc209b478dd4 Mon Sep 17 00:00:00 2001 From: Maufeat Date: Wed, 4 Feb 2026 04:23:08 +0100 Subject: [PATCH] rework the third, as ExternalContentProvider in patch_manager.cpp (less functions) --- .../adapters/ExternalContentAdapter.kt | 53 +++ .../fragments/ExternalContentFragment.kt | 105 +++++ .../fragments/HomeSettingsFragment.kt | 14 + .../model/ExternalContentViewModel.kt | 56 +++ .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 24 + .../org/yuzu/yuzu_emu/utils/NativeConfig.kt | 8 + .../app/src/main/jni/native_config.cpp | 24 +- .../res/layout/card_external_content_dir.xml | 42 ++ .../res/layout/fragment_external_content.xml | 69 +++ .../app/src/main/res/values/strings.xml | 9 + src/common/settings.h | 1 + src/core/file_sys/patch_manager.cpp | 311 +++++++++++-- src/core/file_sys/patch_manager.h | 12 + src/core/file_sys/registered_cache.cpp | 425 +++++++++++++++++- src/core/file_sys/registered_cache.h | 49 +- .../hle/service/filesystem/filesystem.cpp | 37 +- src/core/hle/service/filesystem/filesystem.h | 8 + src/qt_common/config/qt_config.cpp | 19 +- .../configuration/configure_filesystem.cpp | 63 ++- src/yuzu/configuration/configure_filesystem.h | 6 +- .../configuration/configure_filesystem.ui | 60 +++ .../configure_per_game_addons.cpp | 101 ++++- .../configuration/configure_per_game_addons.h | 5 + 23 files changed, 1444 insertions(+), 57 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ExternalContentAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ExternalContentFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/ExternalContentViewModel.kt create mode 100644 src/android/app/src/main/res/layout/card_external_content_dir.xml create mode 100644 src/android/app/src/main/res/layout/fragment_external_content.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ExternalContentAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ExternalContentAdapter.kt new file mode 100644 index 0000000000..b28ae73a9b --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ExternalContentAdapter.kt @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.databinding.CardExternalContentDirBinding +import org.yuzu.yuzu_emu.model.ExternalContentViewModel + +class ExternalContentAdapter( + private val viewModel: ExternalContentViewModel +) : ListAdapter( + AsyncDifferConfig.Builder(DiffCallback()).build() +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DirectoryViewHolder { + val binding = CardExternalContentDirBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return DirectoryViewHolder(binding) + } + + override fun onBindViewHolder(holder: DirectoryViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class DirectoryViewHolder(val binding: CardExternalContentDirBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(path: String) { + binding.textPath.text = path + binding.buttonRemove.setOnClickListener { + viewModel.removeDirectory(path) + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ExternalContentFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ExternalContentFragment.kt new file mode 100644 index 0000000000..f493ab98ac --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ExternalContentFragment.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.ExternalContentAdapter +import org.yuzu.yuzu_emu.databinding.FragmentExternalContentBinding +import org.yuzu.yuzu_emu.model.ExternalContentViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect + +class ExternalContentFragment : Fragment() { + private var _binding: FragmentExternalContentBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val externalContentViewModel: ExternalContentViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentExternalContentBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarExternalContent.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.listExternalDirs.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = ExternalContentAdapter(externalContentViewModel) + } + + externalContentViewModel.directories.collect(viewLifecycleOwner) { dirs -> + (binding.listExternalDirs.adapter as ExternalContentAdapter).submitList(dirs) + binding.textEmpty.visibility = if (dirs.isEmpty()) View.VISIBLE else View.GONE + } + + val mainActivity = requireActivity() as MainActivity + binding.buttonAdd.setOnClickListener { + mainActivity.getExternalContentDirectory.launch(null) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarExternalContent.updateMargins(left = leftInsets, right = rightInsets) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonAdd.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + binding.listExternalDirs.updateMargins(left = leftInsets, right = rightInsets) + + binding.listExternalDirs.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 464572e777..5db9b57d58 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + package org.yuzu.yuzu_emu.fragments import android.Manifest @@ -176,6 +179,17 @@ class HomeSettingsFragment : Fragment() { } ) ) + add( + HomeSetting( + R.string.manage_external_content, + R.string.manage_external_content_description, + R.drawable.ic_folder, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_externalContentFragment) + } + ) + ) add( HomeSetting( R.string.verify_installed_content, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/ExternalContentViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/ExternalContentViewModel.kt new file mode 100644 index 0000000000..dcfda1e92c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/ExternalContentViewModel.kt @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.utils.NativeConfig + +class ExternalContentViewModel : ViewModel() { + private val _directories = MutableStateFlow(listOf()) + val directories: StateFlow> get() = _directories + + init { + loadDirectories() + } + + private fun loadDirectories() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + _directories.value = NativeConfig.getExternalContentDirs() + } + } + } + + fun addDirectory(dir: DocumentFile) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + val path = dir.uri.toString() + val currentDirs = _directories.value.toMutableList() + if (!currentDirs.contains(path)) { + currentDirs.add(path) + NativeConfig.setExternalContentDirs(currentDirs.toTypedArray()) + _directories.value = currentDirs + } + } + } + } + + fun removeDirectory(path: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + val currentDirs = _directories.value.toMutableList() + currentDirs.remove(path) + NativeConfig.setExternalContentDirs(currentDirs.toTypedArray()) + _directories.value = currentDirs + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 8186a6b18f..f33ee24da7 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -389,6 +389,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } } + val getExternalContentDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processExternalContentDir(result) + } + } + fun processGamesDir(result: Uri, calledFromGameFragment: Boolean = false) { contentResolver.takePersistableUriPermission( result, @@ -410,6 +417,23 @@ class MainActivity : AppCompatActivity(), ThemeProvider { .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) } + fun processExternalContentDir(result: Uri) { + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val uriString = result.toString() + val externalContentViewModel by viewModels() + externalContentViewModel.addDirectory(DocumentFile.fromTreeUri(this, result)!!) + + Toast.makeText( + applicationContext, + R.string.add_directory_success, + Toast.LENGTH_SHORT + ).show() + } + val getProdKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> if (result != null) { processKey(result, "keys") diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt index d53672af26..e759c308bc 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt @@ -204,4 +204,12 @@ object NativeConfig { external fun getSdmcDir(): String @Synchronized external fun setSdmcDir(path: String) + + /** + * External Content Provider + */ + @Synchronized + external fun getExternalContentDirs(): Array + @Synchronized + external fun setExternalContentDirs(dirs: Array) } diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp index 800f3e4569..d2c7f4450a 100644 --- a/src/android/app/src/main/jni/native_config.cpp +++ b/src/android/app/src/main/jni/native_config.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include @@ -581,4 +581,26 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSdmcDir(JNIEnv* env, jobject Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, path); } +jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getExternalContentDirs(JNIEnv* env, + jobject obj) { + const auto& dirs = Settings::values.external_content_dirs; + jobjectArray jdirsArray = + env->NewObjectArray(dirs.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < dirs.size(); ++i) { + env->SetObjectArrayElement(jdirsArray, i, Common::Android::ToJString(env, dirs[i])); + } + return jdirsArray; +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setExternalContentDirs(JNIEnv* env, jobject obj, + jobjectArray jdirs) { + Settings::values.external_content_dirs.clear(); + const int size = env->GetArrayLength(jdirs); + for (int i = 0; i < size; ++i) { + auto jdir = static_cast(env->GetObjectArrayElement(jdirs, i)); + Settings::values.external_content_dirs.push_back(Common::Android::GetJString(env, jdir)); + } +} + } // extern "C" diff --git a/src/android/app/src/main/res/layout/card_external_content_dir.xml b/src/android/app/src/main/res/layout/card_external_content_dir.xml new file mode 100644 index 0000000000..dcb92170d8 --- /dev/null +++ b/src/android/app/src/main/res/layout/card_external_content_dir.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_external_content.xml b/src/android/app/src/main/res/layout/fragment_external_content.xml new file mode 100644 index 0000000000..1d56d4990f --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_external_content.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 3765b36323..546cc89015 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -1745,4 +1745,13 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Manage External Content + Configure directories for loading DLCs/Updates without NAND installation + External Content Directories + Add Directory + Remove + Add directories containing NSP/XCI files with DLCs and Updates. These will be loaded without installing to NAND, saving disk space. + No external content directories configured.\n\nAdd a directory to load DLCs/Updates without NAND installation. + diff --git a/src/common/settings.h b/src/common/settings.h index 85f3cb21cd..8abfa941ec 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -715,6 +715,7 @@ struct Values { Category::DataStorage}; Setting gamecard_path{linkage, std::string(), "gamecard_path", Category::DataStorage}; + std::vector external_content_dirs; // Debugging bool record_frame_times; diff --git a/src/core/file_sys/patch_manager.cpp b/src/core/file_sys/patch_manager.cpp index 657172fb4d..7415814ad0 100644 --- a/src/core/file_sys/patch_manager.cpp +++ b/src/core/file_sys/patch_manager.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project @@ -137,12 +137,71 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const { return exefs; const auto& disabled = Settings::values.disabled_addons[title_id]; - const auto update_disabled = - std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); - // Game Updates + bool update_disabled = true; + std::optional enabled_version; + bool checked_external = false; + + const auto* content_union = dynamic_cast(&content_provider); const auto update_tid = GetUpdateTitleID(title_id); - const auto update = content_provider.GetEntry(update_tid, ContentRecordType::Program); + + if (content_union) { + const auto* external_provider = content_union->GetExternalProvider(); + if (external_provider) { + const auto update_versions = external_provider->ListUpdateVersions(update_tid); + + if (update_versions.size() > 1) { + checked_external = true; + for (const auto& update_entry : update_versions) { + std::string disabled_key = fmt::format("Update@{}", update_entry.version); + if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) { + update_disabled = false; + enabled_version = update_entry.version; + break; + } + } + } else if (update_versions.size() == 1) { + checked_external = true; + if (std::find(disabled.cbegin(), disabled.cend(), "Update") == disabled.cend()) { + update_disabled = false; + enabled_version = update_versions[0].version; + } + } + } + } + + // check for original NAND style + // BUT only if we didn't check external provider (to avoid loading wrong update) + if (!checked_external && update_disabled) { + if (std::find(disabled.cbegin(), disabled.cend(), "Update") == disabled.cend()) { + update_disabled = false; + } + if (std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") == disabled.cend()) { + update_disabled = false; + } + if (std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") == disabled.cend()) { + update_disabled = false; + } + } + + // Game Updates + std::unique_ptr update = nullptr; + + // If we have a specific enabled version from external provider, use it + if (enabled_version.has_value() && content_union) { + const auto* external_provider = content_union->GetExternalProvider(); + if (external_provider) { + auto file = external_provider->GetEntryForVersion(update_tid, ContentRecordType::Program, *enabled_version); + if (file != nullptr) { + update = std::make_unique(file); + } + } + } + + // Fallback to regular content provider - but only if we didn't check external + if (update == nullptr && !checked_external) { + update = content_provider.GetEntry(update_tid, ContentRecordType::Program); + } if (!update_disabled && update != nullptr && update->GetExeFS() != nullptr) { LOG_INFO(Loader, " ExeFS: Update ({}) applied successfully", @@ -447,21 +506,60 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs // Game Updates const auto update_tid = GetUpdateTitleID(title_id); - const auto update_raw = content_provider.GetEntryRaw(update_tid, type); - const auto& disabled = Settings::values.disabled_addons[title_id]; - const auto update_disabled = - std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + + bool update_disabled = true; + std::optional enabled_version; + VirtualFile update_raw = nullptr; + bool checked_external = false; + + const auto* content_union = dynamic_cast(&content_provider); + if (content_union) { + const auto* external_provider = content_union->GetExternalProvider(); + if (external_provider) { + const auto update_versions = external_provider->ListUpdateVersions(update_tid); + + if (update_versions.size() > 1) { + checked_external = true; + for (const auto& update_entry : update_versions) { + std::string disabled_key = fmt::format("Update@{}", update_entry.version); + if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) { + update_disabled = false; + enabled_version = update_entry.version; + update_raw = external_provider->GetEntryForVersion(update_tid, type, update_entry.version); + break; + } + } + } else if (update_versions.size() == 1) { + checked_external = true; + if (std::find(disabled.cbegin(), disabled.cend(), "Update") == disabled.cend()) { + update_disabled = false; + enabled_version = update_versions[0].version; + update_raw = external_provider->GetEntryForVersion(update_tid, type, update_versions[0].version); + } + } + } + } + + if (!checked_external && update_disabled) { + if (std::find(disabled.cbegin(), disabled.cend(), "Update") == disabled.cend() || + std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") == disabled.cend() || + std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") == disabled.cend()) { + update_disabled = false; + } + if (!update_disabled && update_raw == nullptr) { + update_raw = content_provider.GetEntryRaw(update_tid, type); + } + } if (!update_disabled && update_raw != nullptr && base_nca != nullptr) { const auto new_nca = std::make_shared(update_raw, base_nca); if (new_nca->GetStatus() == Loader::ResultStatus::Success && new_nca->GetRomFS() != nullptr) { LOG_INFO(Loader, " RomFS: Update ({}) applied successfully", + enabled_version.has_value() ? FormatTitleVersion(*enabled_version) : FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0))); romfs = new_nca->GetRomFS(); - const auto version = - FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0)); } } else if (!update_disabled && packed_update_raw != nullptr && base_nca != nullptr) { const auto new_nca = std::make_shared(packed_update_raw, base_nca); @@ -490,35 +588,164 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { // Game Updates const auto update_tid = GetUpdateTitleID(title_id); - PatchManager update{update_tid, fs_controller, content_provider}; - const auto metadata = update.GetControlMetadata(); - const auto& nacp = metadata.first; - - const auto update_disabled = - std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); - Patch update_patch = {.enabled = !update_disabled, - .name = "Update", - .version = "", - .type = PatchType::Update, - .program_id = title_id, - .title_id = title_id}; - - if (nacp != nullptr) { - update_patch.version = nacp->GetVersionString(); - out.push_back(update_patch); - } else { - if (content_provider.HasEntry(update_tid, ContentRecordType::Program)) { - const auto meta_ver = content_provider.GetEntryVersion(update_tid); - if (meta_ver.value_or(0) == 0) { - out.push_back(update_patch); - } else { - update_patch.version = FormatTitleVersion(*meta_ver); + + const auto* content_union = dynamic_cast(&content_provider); + + if (content_union) { + const auto* external_provider = content_union->GetExternalProvider(); + if (external_provider) { + const auto update_versions = external_provider->ListUpdateVersions(update_tid); + + if (update_versions.size() > 1) { + for (const auto& update_entry : update_versions) { + std::string version_str = update_entry.version_string; + if (version_str.empty()) { + version_str = FormatTitleVersion(update_entry.version); + } + + std::string patch_name = "Update"; + + std::string disabled_key = fmt::format("Update@{}", update_entry.version); + const auto update_disabled = + std::find(disabled.cbegin(), disabled.cend(), disabled_key) != disabled.cend(); + + Patch update_patch = {.enabled = !update_disabled, + .name = patch_name, + .version = version_str, + .type = PatchType::Update, + .program_id = title_id, + .title_id = update_tid, + .source = PatchSource::External, + .numeric_version = update_entry.version}; + + out.push_back(update_patch); + } + } else if (update_versions.size() == 1) { + const auto& update_entry = update_versions[0]; + + std::string version_str = update_entry.version_string; + + if (version_str.empty()) { + const auto metadata = GetControlMetadata(); + if (metadata.first) { + version_str = metadata.first->GetVersionString(); + } + } + + if (version_str.empty()) { + version_str = FormatTitleVersion(update_entry.version); + } + + const auto update_disabled = + std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + + Patch update_patch = {.enabled = !update_disabled, + .name = "Update", + .version = version_str, + .type = PatchType::Update, + .program_id = title_id, + .title_id = update_tid, + .source = PatchSource::External, + .numeric_version = update_entry.version}; + out.push_back(update_patch); } - } else if (update_raw != nullptr) { - update_patch.version = "PACKED"; + } + + const auto all_updates = content_union->ListEntriesFilterOrigin( + std::nullopt, std::nullopt, ContentRecordType::Program, update_tid); + + for (const auto& [slot, entry] : all_updates) { + if (slot == ContentProviderUnionSlot::External) { + continue; + } + + PatchSource source_type = PatchSource::Unknown; + std::string source_suffix; + + switch (slot) { + case ContentProviderUnionSlot::UserNAND: + case ContentProviderUnionSlot::SysNAND: + source_type = PatchSource::NAND; + source_suffix = " (NAND)"; + break; + case ContentProviderUnionSlot::SDMC: + source_type = PatchSource::NAND; + source_suffix = " (SDMC)"; + break; + default: + break; + } + + std::string version_str; + u32 numeric_ver = 0; + PatchManager update{update_tid, fs_controller, content_provider}; + const auto metadata = update.GetControlMetadata(); + const auto& nacp = metadata.first; + + if (nacp != nullptr) { + version_str = nacp->GetVersionString(); + } + + const auto meta_ver = content_provider.GetEntryVersion(update_tid); + if (meta_ver.has_value()) { + numeric_ver = *meta_ver; + if (version_str.empty() && numeric_ver != 0) { + version_str = FormatTitleVersion(numeric_ver); + } + } + + std::string patch_name = "Update" + source_suffix; + + const auto update_disabled = + std::find(disabled.cbegin(), disabled.cend(), patch_name) != disabled.cend(); + + Patch update_patch = {.enabled = !update_disabled, + .name = patch_name, + .version = version_str, + .type = PatchType::Update, + .program_id = title_id, + .title_id = update_tid, + .source = source_type, + .numeric_version = numeric_ver}; + out.push_back(update_patch); } + } else { + PatchManager update{update_tid, fs_controller, content_provider}; + const auto metadata = update.GetControlMetadata(); + const auto& nacp = metadata.first; + + const auto update_disabled = + std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + Patch update_patch = {.enabled = !update_disabled, + .name = "Update", + .version = "", + .type = PatchType::Update, + .program_id = title_id, + .title_id = title_id, + .source = PatchSource::Unknown, + .numeric_version = 0}; + + if (nacp != nullptr) { + update_patch.version = nacp->GetVersionString(); + out.push_back(update_patch); + } else { + if (content_provider.HasEntry(update_tid, ContentRecordType::Program)) { + const auto meta_ver = content_provider.GetEntryVersion(update_tid); + if (meta_ver.value_or(0) == 0) { + out.push_back(update_patch); + } else { + update_patch.version = FormatTitleVersion(*meta_ver); + update_patch.numeric_version = *meta_ver; + out.push_back(update_patch); + } + } else if (update_raw != nullptr) { + update_patch.version = "PACKED"; + update_patch.source = PatchSource::Packed; + out.push_back(update_patch); + } + } } // General Mods (LayeredFS and IPS) @@ -533,7 +760,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = "Cheats", .type = PatchType::Mod, .program_id = title_id, - .title_id = title_id + .title_id = title_id, + .source = PatchSource::Unknown }); } @@ -579,7 +807,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = types, .type = PatchType::Mod, .program_id = title_id, - .title_id = title_id}); + .title_id = title_id, + .source = PatchSource::Unknown}); } } @@ -603,7 +832,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = types, .type = PatchType::Mod, .program_id = title_id, - .title_id = title_id}); + .title_id = title_id, + .source = PatchSource::Unknown}); } } @@ -635,7 +865,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = std::move(list), .type = PatchType::DLC, .program_id = title_id, - .title_id = dlc_match.back().title_id}); + .title_id = dlc_match.back().title_id, + .source = PatchSource::Unknown}); } return out; diff --git a/src/core/file_sys/patch_manager.h b/src/core/file_sys/patch_manager.h index 552c0fbe23..ecd2086984 100644 --- a/src/core/file_sys/patch_manager.h +++ b/src/core/file_sys/patch_manager.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 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 @@ -28,6 +31,13 @@ class NACP; enum class PatchType { Update, DLC, Mod }; +enum class PatchSource { + Unknown, + NAND, + External, + Packed, +}; + struct Patch { bool enabled; std::string name; @@ -35,6 +45,8 @@ struct Patch { PatchType type; u64 program_id; u64 title_id; + PatchSource source; + u32 numeric_version{0}; }; // A centralized class to manage patches to games. diff --git a/src/core/file_sys/registered_cache.cpp b/src/core/file_sys/registered_cache.cpp index f750a2e871..6b615101b7 100644 --- a/src/core/file_sys/registered_cache.cpp +++ b/src/core/file_sys/registered_cache.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project @@ -13,12 +13,15 @@ #include "common/hex_util.h" #include "common/logging/log.h" #include "common/scope_exit.h" +#include "common/string_util.h" #include "core/crypto/key_manager.h" #include "core/file_sys/card_image.h" #include "core/file_sys/common_funcs.h" #include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.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_concat.h" #include "core/loader/loader.h" @@ -974,6 +977,14 @@ std::optional ContentProviderUnion::GetSlotForEntry( return iter->first; } +const ExternalContentProvider* ContentProviderUnion::GetExternalProvider() const { + auto it = providers.find(ContentProviderUnionSlot::External); + if (it != providers.end() && it->second != nullptr) { + return dynamic_cast(it->second); + } + return nullptr; +} + ManualContentProvider::~ManualContentProvider() = default; void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType content_type, @@ -1036,4 +1047,416 @@ std::vector ManualContentProvider::ListEntriesFilter( out.erase(std::unique(out.begin(), out.end()), out.end()); return out; } + +ExternalContentProvider::ExternalContentProvider(std::vector load_directories) + : load_dirs(std::move(load_directories)) { + ExternalContentProvider::Refresh(); +} + +ExternalContentProvider::~ExternalContentProvider() = default; + +void ExternalContentProvider::AddDirectory(VirtualDir directory) { + if (directory != nullptr) { + load_dirs.push_back(std::move(directory)); + ScanDirectory(load_dirs.back()); + } +} + +void ExternalContentProvider::ClearDirectories() { + load_dirs.clear(); + entries.clear(); + versions.clear(); + multi_version_entries.clear(); +} + +void ExternalContentProvider::Refresh() { + entries.clear(); + versions.clear(); + multi_version_entries.clear(); + for (const auto& dir : load_dirs) { + if (dir != nullptr) { + ScanDirectory(dir); + } + } +} + +void ExternalContentProvider::ScanDirectory(const VirtualDir& dir) { + if (dir == nullptr) { + return; + } + + for (const auto& file : dir->GetFiles()) { + const auto filename = file->GetName(); + const auto dot_pos = filename.find_last_of('.'); + + if (dot_pos == std::string::npos) { + continue; + } + + const auto extension = Common::ToLower(filename.substr(dot_pos + 1)); + + if (extension == "nsp") { + ProcessNSP(file); + } else if (extension == "xci") { + ProcessXCI(file); + } + } + + for (const auto& subdir : dir->GetSubdirectories()) { + ScanDirectory(subdir); + } +} + +void ExternalContentProvider::ProcessNSP(const VirtualFile& file) { + auto nsp = NSP(file); + if (nsp.GetStatus() != Loader::ResultStatus::Success) { + return; + } + + const auto ncas = nsp.GetNCAs(); + + std::map nsp_versions; + std::map nsp_version_strings; // title_id -> NACP version string + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (content_type == ContentRecordType::Meta) { + const auto subdirs = nca->GetSubdirectories(); + if (!subdirs.empty()) { + const auto section0 = subdirs[0]; + const auto files = section0->GetFiles(); + for (const auto& inner_file : files) { + if (inner_file->GetExtension() == "cnmt") { + const CNMT cnmt(inner_file); + const auto cnmt_title_id = cnmt.GetTitleID(); + const auto version = cnmt.GetTitleVersion(); + nsp_versions[cnmt_title_id] = version; + versions[cnmt_title_id] = version; + break; + } + } + } + } + + if (content_type == ContentRecordType::Control && title_type == TitleType::Update) { + auto romfs = nca->GetRomFS(); + if (romfs) { + auto extracted = ExtractRomFS(romfs); + if (extracted) { + auto nacp_file = extracted->GetFile("control.nacp"); + if (!nacp_file) { + nacp_file = extracted->GetFile("Control.nacp"); + } + if (nacp_file) { + NACP nacp(nacp_file); + auto ver_str = nacp.GetVersionString(); + if (!ver_str.empty()) { + nsp_version_strings[title_id] = ver_str; + } + } + } + } + } + } + } + + std::map, std::map> version_files; + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (title_type != TitleType::AOC && title_type != TitleType::Update) { + continue; + } + + auto nca_file = nsp.GetNCAFile(title_id, content_type, title_type); + if (nca_file != nullptr) { + entries[{title_id, content_type, title_type}] = nca_file; + + if (title_type == TitleType::Update) { + u32 version = 0; + auto ver_it = nsp_versions.find(title_id); + if (ver_it != nsp_versions.end()) { + version = ver_it->second; + } + + version_files[{title_id, version}][content_type] = nca_file; + } + + LOG_DEBUG(Service_FS, "Added entry - Title ID: {:016X}, Type: {}, Content: {}", + title_id, static_cast(title_type), static_cast(content_type)); + } + } + } + + for (const auto& [key, files_map] : version_files) { + const auto& [title_id, version] = key; + + std::string ver_str; + auto str_it = nsp_version_strings.find(title_id); + if (str_it != nsp_version_strings.end()) { + ver_str = str_it->second; + } + + bool version_exists = false; + for (auto& existing : multi_version_entries) { + if (existing.title_id == title_id && existing.version == version) { + for (const auto& [content_type, _file] : files_map) { + existing.files[content_type] = _file; + } + if (existing.version_string.empty() && !ver_str.empty()) { + existing.version_string = ver_str; + } + version_exists = true; + break; + } + } + + if (!version_exists && !files_map.empty()) { + ExternalUpdateEntry update_entry{ + .title_id = title_id, + .version = version, + .version_string = ver_str, + .files = files_map + }; + multi_version_entries.push_back(update_entry); + LOG_DEBUG(Service_FS, "Added multi-version update - Title ID: {:016X}, Version: {}, VersionStr: {}, Content types: {}", + title_id, version, ver_str, files_map.size()); + } + } +} + +void ExternalContentProvider::ProcessXCI(const VirtualFile& file) { + auto xci = XCI(file); + if (xci.GetStatus() != Loader::ResultStatus::Success) { + return; + } + + auto nsp = xci.GetSecurePartitionNSP(); + if (nsp == nullptr) { + return; + } + + const auto ncas = nsp->GetNCAs(); + + std::map xci_versions; + std::map xci_version_strings; + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (content_type == ContentRecordType::Meta) { + const auto subdirs = nca->GetSubdirectories(); + if (!subdirs.empty()) { + const auto section0 = subdirs[0]; + const auto files = section0->GetFiles(); + for (const auto& inner_file : files) { + if (inner_file->GetExtension() == "cnmt") { + const CNMT cnmt(inner_file); + const auto cnmt_title_id = cnmt.GetTitleID(); + const auto version = cnmt.GetTitleVersion(); + xci_versions[cnmt_title_id] = version; + versions[cnmt_title_id] = version; + break; + } + } + } + } + + if (content_type == ContentRecordType::Control && title_type == TitleType::Update) { + auto romfs = nca->GetRomFS(); + if (romfs) { + auto extracted = ExtractRomFS(romfs); + if (extracted) { + auto nacp_file = extracted->GetFile("control.nacp"); + if (!nacp_file) { + nacp_file = extracted->GetFile("Control.nacp"); + } + if (nacp_file) { + NACP nacp(nacp_file); + auto ver_str = nacp.GetVersionString(); + if (!ver_str.empty()) { + xci_version_strings[title_id] = ver_str; + } + } + } + } + } + } + } + + std::map, std::map> version_files; + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (title_type != TitleType::AOC && title_type != TitleType::Update) { + continue; + } + + auto nca_file = nsp->GetNCAFile(title_id, content_type, title_type); + if (nca_file != nullptr) { + entries[{title_id, content_type, title_type}] = nca_file; + + if (title_type == TitleType::Update) { + u32 version = 0; + auto ver_it = xci_versions.find(title_id); + if (ver_it != xci_versions.end()) { + version = ver_it->second; + } + + version_files[{title_id, version}][content_type] = nca_file; + } + } + } + } + + for (const auto& [key, files_map] : version_files) { + const auto& [title_id, version] = key; + + std::string ver_str; + auto str_it = xci_version_strings.find(title_id); + if (str_it != xci_version_strings.end()) { + ver_str = str_it->second; + } + + bool version_exists = false; + for (auto& existing : multi_version_entries) { + if (existing.title_id == title_id && existing.version == version) { + for (const auto& [content_type, _file] : files_map) { + existing.files[content_type] = _file; + } + if (existing.version_string.empty() && !ver_str.empty()) { + existing.version_string = ver_str; + } + version_exists = true; + break; + } + } + + if (!version_exists && !files_map.empty()) { + ExternalUpdateEntry update_entry{ + .title_id = title_id, + .version = version, + .version_string = ver_str, + .files = files_map + }; + multi_version_entries.push_back(update_entry); + LOG_DEBUG(Service_FS, "Added multi-version update from XCI - Title ID: {:016X}, Version: {}, VersionStr: {}, Content types: {}", + title_id, version, ver_str, files_map.size()); + } + } +} + +bool ExternalContentProvider::HasEntry(u64 title_id, ContentRecordType type) const { + return GetEntryRaw(title_id, type) != nullptr; +} + +std::optional ExternalContentProvider::GetEntryVersion(u64 title_id) const { + const auto it = versions.find(title_id); + if (it != versions.end()) { + return it->second; + } + return std::nullopt; +} + +VirtualFile ExternalContentProvider::GetEntryUnparsed(u64 title_id, ContentRecordType type) const { + return GetEntryRaw(title_id, type); +} + +VirtualFile ExternalContentProvider::GetEntryRaw(u64 title_id, ContentRecordType type) const { + // Try to find in AOC (DLC) entries + { + const auto it = entries.find({title_id, type, TitleType::AOC}); + if (it != entries.end()) { + return it->second; + } + } + + // Try to find in Update entries + { + const auto it = entries.find({title_id, type, TitleType::Update}); + if (it != entries.end()) { + return it->second; + } + } + + return nullptr; +} + +std::unique_ptr ExternalContentProvider::GetEntry(u64 title_id, + ContentRecordType type) const { + const auto file = GetEntryRaw(title_id, type); + if (file == nullptr) { + return nullptr; + } + return std::make_unique(file); +} + +std::vector ExternalContentProvider::ListEntriesFilter( + std::optional title_type, std::optional record_type, + std::optional title_id) const { + std::vector out; + + for (const auto& [key, file] : entries) { + const auto& [e_title_id, e_content_type, e_title_type] = key; + + if ((title_type == std::nullopt || e_title_type == *title_type) && + (record_type == std::nullopt || e_content_type == *record_type) && + (title_id == std::nullopt || e_title_id == *title_id)) { + out.emplace_back(ContentProviderEntry{e_title_id, e_content_type}); + } + } + + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +std::vector ExternalContentProvider::ListUpdateVersions(u64 title_id) const { + std::vector out; + + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id) { + out.push_back(entry); + } + } + + std::sort(out.begin(), out.end(), [](const ExternalUpdateEntry& a, const ExternalUpdateEntry& b) { + return a.version > b.version; + }); + + return out; +} + +VirtualFile ExternalContentProvider::GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const { + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id && entry.version == version) { + auto it = entry.files.find(type); + if (it != entry.files.end()) { + return it->second; + } + } + } + return nullptr; +} + +bool ExternalContentProvider::HasMultipleVersions(u64 title_id, ContentRecordType type) const { + size_t count = 0; + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id && entry.files.count(type) > 0) { + count++; + if (count > 1) { + return true; + } + } + } + return false; +} + } // namespace FileSys diff --git a/src/core/file_sys/registered_cache.h b/src/core/file_sys/registered_cache.h index a7fc556737..b820fad8d9 100644 --- a/src/core/file_sys/registered_cache.h +++ b/src/core/file_sys/registered_cache.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 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 @@ -14,7 +17,8 @@ #include "core/file_sys/vfs/vfs.h" namespace FileSys { -class CNMT; + class ExternalContentProvider; + class CNMT; class NCA; class NSP; class XCI; @@ -48,6 +52,13 @@ struct ContentProviderEntry { std::string DebugInfo() const; }; +struct ExternalUpdateEntry { + u64 title_id; + u32 version; + std::string version_string; + std::map files; +}; + constexpr u64 GetUpdateTitleID(u64 base_title_id) { return base_title_id | 0x800; } @@ -208,6 +219,7 @@ enum class ContentProviderUnionSlot { UserNAND, ///< User NAND SDMC, ///< SD Card FrontendManual, ///< Frontend-defined game list or similar + External, ///< External content from NSP/XCI files in configured directories }; // Combines multiple ContentProvider(s) (i.e. SysNAND, UserNAND, SDMC) into one interface. @@ -228,6 +240,8 @@ public: std::optional title_type, std::optional record_type, std::optional title_id) const override; + const ExternalContentProvider* GetExternalProvider() const; + std::vector> ListEntriesFilterOrigin( std::optional origin = {}, std::optional title_type = {}, std::optional record_type = {}, @@ -262,4 +276,37 @@ private: std::map, VirtualFile> entries; }; +class ExternalContentProvider : public ContentProvider { +public: + explicit ExternalContentProvider(std::vector load_directories = {}); + ~ExternalContentProvider() override; + + void AddDirectory(VirtualDir directory); + void ClearDirectories(); + + void Refresh() override; + bool HasEntry(u64 title_id, ContentRecordType type) const override; + std::optional GetEntryVersion(u64 title_id) const override; + VirtualFile GetEntryUnparsed(u64 title_id, ContentRecordType type) const override; + VirtualFile GetEntryRaw(u64 title_id, ContentRecordType type) const override; + std::unique_ptr GetEntry(u64 title_id, ContentRecordType type) const override; + std::vector ListEntriesFilter( + std::optional title_type, std::optional record_type, + std::optional title_id) const override; + + std::vector ListUpdateVersions(u64 title_id) const; + VirtualFile GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const; + bool HasMultipleVersions(u64 title_id, ContentRecordType type) const; + +private: + void ScanDirectory(const VirtualDir& dir); + void ProcessNSP(const VirtualFile& file); + void ProcessXCI(const VirtualFile& file); + + std::vector load_dirs; + std::map, VirtualFile> entries; + std::map versions; + std::vector multi_version_entries; +}; + } // namespace FileSys diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 95a32c1250..700f2b46c4 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project @@ -9,6 +9,7 @@ #include "common/assert.h" #include "common/fs/fs.h" #include "common/fs/path_util.h" +#include "common/logging/log.h" #include "common/settings.h" #include "core/core.h" #include "core/file_sys/bis_factory.h" @@ -507,6 +508,10 @@ FileSys::RegisteredCache* FileSystemController::GetSDMCContents() const { return sdmc_factory->GetSDMCContents(); } +FileSys::ExternalContentProvider* FileSystemController::GetExternalContentProvider() const { + return external_provider.get(); +} + FileSys::PlaceholderCache* FileSystemController::GetSystemNANDPlaceholder() const { LOG_TRACE(Service_FS, "Opening System NAND Placeholder"); @@ -716,6 +721,36 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC, sdmc_factory->GetSDMCContents()); } + + if (external_provider == nullptr) { + std::vector external_dirs; + + LOG_DEBUG(Service_FS, "Initializing ExternalContentProvider with {} configured directories", + Settings::values.external_content_dirs.size()); + + for (const auto& dir_path : Settings::values.external_content_dirs) { + if (!dir_path.empty()) { + LOG_DEBUG(Service_FS, "Attempting to open directory: {}", dir_path); + auto dir = vfs.OpenDirectory(dir_path, FileSys::OpenMode::Read); + if (dir != nullptr) { + external_dirs.push_back(std::move(dir)); + LOG_DEBUG(Service_FS, "Successfully opened directory: {}", dir_path); + } else { + LOG_ERROR(Service_FS, "Failed to open directory: {}", dir_path); + } + } + } + + LOG_DEBUG(Service_FS, "Creating ExternalContentProvider with {} opened directories", + external_dirs.size()); + + external_provider = std::make_unique( + std::move(external_dirs)); + system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::External, + external_provider.get()); + + LOG_DEBUG(Service_FS, "ExternalContentProvider registered to content provider union"); + } } void FileSystemController::Reset() { diff --git a/src/core/hle/service/filesystem/filesystem.h b/src/core/hle/service/filesystem/filesystem.h index 718500385b..ef45aec627 100644 --- a/src/core/hle/service/filesystem/filesystem.h +++ b/src/core/hle/service/filesystem/filesystem.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 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 @@ -17,6 +20,7 @@ class System; namespace FileSys { class BISFactory; +class ExternalContentProvider; class NCA; class RegisteredCache; class RegisteredCacheUnion; @@ -117,6 +121,8 @@ public: FileSys::VirtualDir GetBCATDirectory(u64 title_id) const; + FileSys::ExternalContentProvider* GetExternalContentProvider() const; + // Creates the SaveData, SDMC, and BIS Factories. Should be called once and before any function // above is called. void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true); @@ -138,6 +144,8 @@ private: std::unique_ptr sdmc_factory; std::unique_ptr bis_factory; + std::unique_ptr external_provider; + std::unique_ptr gamecard; std::unique_ptr gamecard_registered; std::unique_ptr gamecard_placeholder; diff --git a/src/qt_common/config/qt_config.cpp b/src/qt_common/config/qt_config.cpp index 65bf488c5c..c5a8f62745 100644 --- a/src/qt_common/config/qt_config.cpp +++ b/src/qt_common/config/qt_config.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -231,6 +231,16 @@ void QtConfig::ReadPathValues() { QString::fromStdString(ReadStringSetting(std::string("recentFiles"))) .split(QStringLiteral(", "), Qt::SkipEmptyParts, Qt::CaseSensitive); + const int external_dirs_size = BeginArray(std::string("external_content_dirs")); + for (int i = 0; i < external_dirs_size; ++i) { + SetArrayIndex(i); + std::string dir_path = ReadStringSetting(std::string("path")); + if (!dir_path.empty()) { + Settings::values.external_content_dirs.push_back(dir_path); + } + } + EndArray(); + ReadCategory(Settings::Category::Paths); EndGroup(); @@ -446,6 +456,13 @@ void QtConfig::SavePathValues() { WriteStringSetting(std::string("recentFiles"), UISettings::values.recent_files.join(QStringLiteral(", ")).toStdString()); + BeginArray(std::string("external_content_dirs")); + for (int i = 0; i < static_cast(Settings::values.external_content_dirs.size()); ++i) { + SetArrayIndex(i); + WriteStringSetting(std::string("path"), Settings::values.external_content_dirs[i]); + } + EndArray(); + EndGroup(); } diff --git a/src/yuzu/configuration/configure_filesystem.cpp b/src/yuzu/configuration/configure_filesystem.cpp index 545032eee3..22b2dc802d 100644 --- a/src/yuzu/configuration/configure_filesystem.cpp +++ b/src/yuzu/configuration/configure_filesystem.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project @@ -38,10 +38,19 @@ ConfigureFilesystem::ConfigureFilesystem(QWidget* parent) connect(ui->reset_game_list_cache, &QPushButton::pressed, this, &ConfigureFilesystem::ResetMetadata); - connect(ui->gamecard_inserted, &QCheckBox::STATE_CHANGED, this, + connect(ui->gamecard_inserted, &QCheckBox::stateChanged, this, &ConfigureFilesystem::UpdateEnabledControls); - connect(ui->gamecard_current_game, &QCheckBox::STATE_CHANGED, this, + connect(ui->gamecard_current_game, &QCheckBox::stateChanged, this, &ConfigureFilesystem::UpdateEnabledControls); + + connect(ui->add_external_dir_button, &QPushButton::pressed, this, + &ConfigureFilesystem::AddExternalContentDirectory); + connect(ui->remove_external_dir_button, &QPushButton::pressed, this, + &ConfigureFilesystem::RemoveSelectedExternalContentDirectory); + connect(ui->external_content_list, &QListWidget::itemSelectionChanged, this, [this] { + ui->remove_external_dir_button->setEnabled( + !ui->external_content_list->selectedItems().isEmpty()); + }); } ConfigureFilesystem::~ConfigureFilesystem() = default; @@ -75,6 +84,7 @@ void ConfigureFilesystem::SetConfiguration() { ui->cache_game_list->setChecked(UISettings::values.cache_game_list.GetValue()); + UpdateExternalContentList(); UpdateEnabledControls(); } @@ -96,6 +106,12 @@ void ConfigureFilesystem::ApplyConfiguration() { Settings::values.dump_nso = ui->dump_nso->isChecked(); UISettings::values.cache_game_list = ui->cache_game_list->isChecked(); + + Settings::values.external_content_dirs.clear(); + for (int i = 0; i < ui->external_content_list->count(); ++i) { + Settings::values.external_content_dirs.push_back( + ui->external_content_list->item(i)->text().toStdString()); + } } void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit) { @@ -120,6 +136,9 @@ void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit) case DirectoryTarget::Load: caption = tr("Select Mod Load Directory..."); break; + case DirectoryTarget::ExternalContent: + caption = tr("Select External Content Directory..."); + break; } QString str; @@ -278,6 +297,44 @@ void ConfigureFilesystem::UpdateEnabledControls() { !ui->gamecard_current_game->isChecked()); } +void ConfigureFilesystem::UpdateExternalContentList() { + ui->external_content_list->clear(); + for (const auto& dir : Settings::values.external_content_dirs) { + ui->external_content_list->addItem(QString::fromStdString(dir)); + } +} + +void ConfigureFilesystem::AddExternalContentDirectory() { + const QString dir_path = QFileDialog::getExistingDirectory( + this, tr("Select External Content Directory..."), QString()); + + if (dir_path.isEmpty()) { + return; + } + + QString normalized_path = QDir::toNativeSeparators(dir_path); + if (normalized_path.back() != QDir::separator()) { + normalized_path.append(QDir::separator()); + } + + for (int i = 0; i < ui->external_content_list->count(); ++i) { + if (ui->external_content_list->item(i)->text() == normalized_path) { + QMessageBox::information(this, tr("Directory Already Added"), + tr("This directory is already in the list.")); + return; + } + } + + ui->external_content_list->addItem(normalized_path); +} + +void ConfigureFilesystem::RemoveSelectedExternalContentDirectory() { + auto selected = ui->external_content_list->selectedItems(); + if (!selected.isEmpty()) { + qDeleteAll(ui->external_content_list->selectedItems()); + } +} + void ConfigureFilesystem::RetranslateUI() { ui->retranslateUi(this); } diff --git a/src/yuzu/configuration/configure_filesystem.h b/src/yuzu/configuration/configure_filesystem.h index d8c26a783a..9999b39fe4 100644 --- a/src/yuzu/configuration/configure_filesystem.h +++ b/src/yuzu/configuration/configure_filesystem.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project @@ -37,6 +37,7 @@ private: Gamecard, Dump, Load, + ExternalContent, }; void SetDirectory(DirectoryTarget target, QLineEdit* edit); @@ -44,6 +45,9 @@ private: void PromptSaveMigration(const QString& from_path, const QString& to_path); void ResetMetadata(); void UpdateEnabledControls(); + void UpdateExternalContentList(); + void AddExternalContentDirectory(); + void RemoveSelectedExternalContentDirectory(); std::unique_ptr ui; }; diff --git a/src/yuzu/configuration/configure_filesystem.ui b/src/yuzu/configuration/configure_filesystem.ui index 75c61c74a6..5dca559281 100644 --- a/src/yuzu/configuration/configure_filesystem.ui +++ b/src/yuzu/configuration/configure_filesystem.ui @@ -239,6 +239,66 @@ + + + + External Content + + + + + + Add directories to scan for DLCs and Updates without installing to NAND + + + true + + + + + + + QAbstractItemView::SingleSelection + + + + + + + + + Add Directory + + + + + + + Remove Selected + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + diff --git a/src/yuzu/configuration/configure_per_game_addons.cpp b/src/yuzu/configuration/configure_per_game_addons.cpp index ee2db55a5d..462a9c3a8a 100644 --- a/src/yuzu/configuration/configure_per_game_addons.cpp +++ b/src/yuzu/configuration/configure_per_game_addons.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2016 Citra Emulator Project @@ -8,6 +8,8 @@ #include #include +#include + #include #include #include @@ -15,6 +17,7 @@ #include #include +#include "common/common_types.h" #include "common/fs/fs.h" #include "common/fs/path_util.h" #include "core/core.h" @@ -64,19 +67,45 @@ 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); }); } ConfigurePerGameAddons::~ConfigurePerGameAddons() = default; +void ConfigurePerGameAddons::OnItemChanged(QStandardItem* item) { + if (update_items.size() > 1 && item->checkState() == Qt::Checked) { + auto it = std::find(update_items.begin(), update_items.end(), item); + if (it != update_items.end()) { + for (auto* update_item : update_items) { + if (update_item != item && update_item->checkState() == Qt::Checked) { + disconnect(item_model, &QStandardItemModel::itemChanged, this, + &ConfigurePerGameAddons::OnItemChanged); + update_item->setCheckState(Qt::Unchecked); + connect(item_model, &QStandardItemModel::itemChanged, this, + &ConfigurePerGameAddons::OnItemChanged); + } + } + } + } +} + void ConfigurePerGameAddons::ApplyConfiguration() { std::vector disabled_addons; for (const auto& item : list_items) { const auto disabled = item.front()->checkState() == Qt::Unchecked; - if (disabled) - disabled_addons.push_back(item.front()->text().toStdString()); + if (disabled) { + QVariant userData = item.front()->data(Qt::UserRole); + if (userData.isValid() && userData.canConvert() && item.front()->text() == QStringLiteral("Update")) { + quint32 numeric_version = userData.toUInt(); + disabled_addons.push_back(fmt::format("Update@{}", numeric_version)); + } else { + disabled_addons.push_back(item.front()->text().toStdString()); + } + } } auto current = Settings::values.disabled_addons[title_id]; @@ -125,17 +154,73 @@ void ConfigurePerGameAddons::LoadConfiguration() { const auto& disabled = Settings::values.disabled_addons[title_id]; - for (const auto& patch : pm.GetPatches(update_raw)) { + update_items.clear(); + list_items.clear(); + item_model->removeRows(0, item_model->rowCount()); + + std::vector patches = pm.GetPatches(update_raw); + + size_t multi_version_update_count = 0; + for (const auto& patch : patches) { + if (patch.type == FileSys::PatchType::Update && patch.numeric_version != 0) { + multi_version_update_count++; + } + } + + bool has_saved_multi_version_settings = false; + if (multi_version_update_count > 1) { + for (const auto& patch : patches) { + if (patch.type == FileSys::PatchType::Update && patch.numeric_version != 0) { + std::string disabled_key = fmt::format("Update@{}", patch.numeric_version); + if (std::find(disabled.begin(), disabled.end(), disabled_key) != disabled.end()) { + has_saved_multi_version_settings = true; + break; + } + } + } + } + + bool has_enabled_update = false; + bool is_first_multi_version_update = true; + + for (const auto& patch : patches) { const auto name = QString::fromStdString(patch.name); auto* const first_item = new QStandardItem; first_item->setText(name); first_item->setCheckable(true); - const auto patch_disabled = - std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end(); - - first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked); + if (patch.type == FileSys::PatchType::Update && patch.numeric_version != 0) { + first_item->setData(static_cast(patch.numeric_version), Qt::UserRole); + } + + bool patch_disabled = false; + if (patch.type == FileSys::PatchType::Update && patch.numeric_version != 0 && multi_version_update_count > 1) { + if (has_saved_multi_version_settings) { + std::string disabled_key = fmt::format("Update@{}", patch.numeric_version); + patch_disabled = std::find(disabled.begin(), disabled.end(), disabled_key) != disabled.end(); + } else { + patch_disabled = !is_first_multi_version_update; + } + is_first_multi_version_update = false; + } else { + patch_disabled = std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end(); + } + + bool should_enable = !patch_disabled; + + if (patch.type == FileSys::PatchType::Update) { + if (should_enable) { + if (has_enabled_update) { + should_enable = false; + } else { + has_enabled_update = true; + } + } + update_items.push_back(first_item); + } + + first_item->setCheckState(should_enable ? Qt::Checked : Qt::Unchecked); list_items.push_back(QList{ first_item, new QStandardItem{QString::fromStdString(patch.version)}}); diff --git a/src/yuzu/configuration/configure_per_game_addons.h b/src/yuzu/configuration/configure_per_game_addons.h index 32dc5dde62..af2e16422c 100644 --- a/src/yuzu/configuration/configure_per_game_addons.h +++ b/src/yuzu/configuration/configure_per_game_addons.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 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 @@ -43,6 +46,7 @@ private: void RetranslateUI(); void LoadConfiguration(); + void OnItemChanged(QStandardItem* item); std::unique_ptr ui; FileSys::VirtualFile file; @@ -53,6 +57,7 @@ private: QStandardItemModel* item_model; std::vector> list_items; + std::vector update_items; Core::System& system; };