[fs/core] Load external content without NAND install (#2862)

Adds the capability to add DLC and Updates without installing them to NAND. This was tested on Windows only and needs Android integration.

Co-authored-by: crueter <crueter@eden-emu.dev>
Co-authored-by: wildcard <wildcard@eden-emu.dev>
Co-authored-by: nekle <nekle@protonmail.com>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/2862
Reviewed-by: DraVee <dravee@eden-emu.dev>
Reviewed-by: crueter <crueter@eden-emu.dev>
Co-authored-by: Maufeat <sahyno1996@gmail.com>
Co-committed-by: Maufeat <sahyno1996@gmail.com>
This commit is contained in:
Maufeat 2026-02-06 14:05:44 +01:00 committed by crueter
parent e07e269bd7
commit 69aff83ef4
No known key found for this signature in database
GPG Key ID: 425ACD2D4830EBC6
40 changed files with 1790 additions and 126 deletions

View File

@ -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
@ -10,6 +10,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding
import org.yuzu.yuzu_emu.model.Patch
import org.yuzu.yuzu_emu.model.PatchType
import org.yuzu.yuzu_emu.model.AddonViewModel
import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
@ -31,8 +32,13 @@ class AddonAdapter(val addonViewModel: AddonViewModel) :
binding.addonSwitch.isChecked = model.enabled
binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
if (PatchType.from(model.type) == PatchType.Update && checked) {
addonViewModel.enableOnlyThisUpdate(model)
notifyDataSetChanged()
} else {
model.enabled = checked
}
}
val deleteAction = {
addonViewModel.setAddonToDelete(model)

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@ -7,8 +10,10 @@ import android.net.Uri
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.fragment.app.FragmentActivity
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.CardFolderBinding
import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment
import org.yuzu.yuzu_emu.model.DirectoryType
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.ViewUtils.marquee
@ -31,6 +36,12 @@ class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesVie
path.text = Uri.parse(model.uriString).path
path.marquee()
// Set type indicator, shows below folder name, to see if DLC or Games
typeIndicator.text = when (model.type) {
DirectoryType.GAME -> activity.getString(R.string.games)
DirectoryType.EXTERNAL_CONTENT -> activity.getString(R.string.external_content)
}
buttonEdit.setOnClickListener {
GameFolderPropertiesDialogFragment.newInstance(model)
.show(

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@ -6,11 +9,13 @@ package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding
import org.yuzu.yuzu_emu.model.DirectoryType
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.NativeConfig
@ -25,15 +30,19 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
val binding = DialogFolderPropertiesBinding.inflate(layoutInflater)
val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!!
// Restore checkbox state
// Hide deepScan for external content, do automatically
if (gameDir.type == DirectoryType.EXTERNAL_CONTENT) {
binding.deepScanSwitch.visibility = View.GONE
} else {
// Restore checkbox state for game dirs
binding.deepScanSwitch.isChecked =
savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan
// Ensure that we can get the checkbox state even if the view is destroyed
deepScan = binding.deepScanSwitch.isChecked
binding.deepScanSwitch.setOnClickListener {
deepScan = binding.deepScanSwitch.isChecked
}
}
return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root)
@ -41,8 +50,10 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
val folderIndex = gamesViewModel.folders.value.indexOf(gameDir)
if (folderIndex != -1) {
if (gameDir.type == DirectoryType.GAME) {
gamesViewModel.folders.value[folderIndex].deepScan =
binding.deepScanSwitch.isChecked
}
gamesViewModel.updateGameDirs()
}
}

View File

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2025 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.fragments
@ -15,11 +15,14 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialSharedAxis
import kotlinx.coroutines.launch
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.FolderAdapter
import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding
import org.yuzu.yuzu_emu.model.DirectoryType
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.ui.main.MainActivity
@ -73,8 +76,26 @@ class GameFoldersFragment : Fragment() {
val mainActivity = requireActivity() as MainActivity
binding.buttonAdd.setOnClickListener {
// Show a model to choose between Game and External Content
val options = arrayOf(
getString(R.string.games),
getString(R.string.external_content)
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.add_folders)
.setItems(options) { _, which ->
when (which) {
0 -> { // Game Folder
mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
}
1 -> { // External Content Folder
mainActivity.getExternalContentDirectory.launch(null)
}
}
}
.show()
}
setInsets()
}

View File

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

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@ -48,16 +51,68 @@ class AddonViewModel : ViewModel() {
?: emptyArray()
).toMutableList()
patchList.sortBy { it.name }
// Ensure only one update is enabled
ensureSingleUpdateEnabled(patchList)
removeDuplicates(patchList)
_patchList.value = patchList
isRefreshing.set(false)
}
}
}
private fun ensureSingleUpdateEnabled(patchList: MutableList<Patch>) {
val updates = patchList.filter { PatchType.from(it.type) == PatchType.Update }
if (updates.size <= 1) {
return
}
val enabledUpdates = updates.filter { it.enabled }
if (enabledUpdates.size > 1) {
val nandOrSdmcEnabled = enabledUpdates.find {
it.name.contains("(NAND)") || it.name.contains("(SDMC)")
}
val updateToKeep = nandOrSdmcEnabled ?: enabledUpdates.first()
for (patch in patchList) {
if (PatchType.from(patch.type) == PatchType.Update) {
patch.enabled = (patch === updateToKeep)
}
}
}
}
private fun removeDuplicates(patchList: MutableList<Patch>) {
val seen = mutableSetOf<String>()
val iterator = patchList.iterator()
while (iterator.hasNext()) {
val patch = iterator.next()
val key = "${patch.name}|${patch.version}|${patch.type}"
if (seen.contains(key)) {
iterator.remove()
} else {
seen.add(key)
}
}
}
fun setAddonToDelete(patch: Patch?) {
_addonToDelete.value = patch
}
fun enableOnlyThisUpdate(selectedPatch: Patch) {
val currentList = _patchList.value
for (patch in currentList) {
if (PatchType.from(patch.type) == PatchType.Update) {
patch.enabled = (patch === selectedPatch)
}
}
}
fun onDeleteAddon(patch: Patch) {
when (PatchType.from(patch.type)) {
PatchType.Update -> NativeLibrary.removeUpdate(patch.programId)
@ -72,13 +127,27 @@ class AddonViewModel : ViewModel() {
return
}
// Check if there are multiple update versions
val updates = _patchList.value.filter { PatchType.from(it.type) == PatchType.Update }
val hasMultipleUpdates = updates.size > 1
NativeConfig.setDisabledAddons(
game!!.programId,
_patchList.value.mapNotNull {
if (it.enabled) {
null
} else {
if (PatchType.from(it.type) == PatchType.Update) {
if (it.name.contains("(NAND)") || it.name.contains("(SDMC)")) {
it.name
} else if (hasMultipleUpdates) {
"Update@${it.numericVersion}"
} else {
it.name
}
} else {
it.name
}
}
}.toTypedArray()
)

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@ -9,5 +12,14 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class GameDir(
val uriString: String,
var deepScan: Boolean
) : Parcelable
var deepScan: Boolean,
val type: DirectoryType = DirectoryType.GAME
) : Parcelable {
// Needed for JNI backward compatability
constructor(uriString: String, deepScan: Boolean) : this(uriString, deepScan, DirectoryType.GAME)
}
enum class DirectoryType {
GAME,
EXTERNAL_CONTENT
}

View File

@ -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
package org.yuzu.yuzu_emu.model
@ -56,7 +56,7 @@ class GamesViewModel : ViewModel() {
// Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys()
getGameDirs()
getGameDirsAndExternalContent()
reloadGames(directoriesChanged = false, firstStartup = true)
}
@ -144,11 +144,19 @@ class GamesViewModel : ViewModel() {
fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) =
viewModelScope.launch {
withContext(Dispatchers.IO) {
when (gameDir.type) {
DirectoryType.GAME -> {
NativeConfig.addGameDir(gameDir)
val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
.getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true)
getGameDirs(!isFirstTimeSetup)
getGameDirsAndExternalContent(!isFirstTimeSetup)
}
DirectoryType.EXTERNAL_CONTENT -> {
addExternalContentDir(gameDir.uriString)
NativeConfig.saveGlobalConfig()
getGameDirsAndExternalContent()
}
}
}
if (savedFromGameFragment) {
@ -168,8 +176,15 @@ class GamesViewModel : ViewModel() {
val removedDirIndex = gameDirs.indexOf(gameDir)
if (removedDirIndex != -1) {
gameDirs.removeAt(removedDirIndex)
NativeConfig.setGameDirs(gameDirs.toTypedArray())
getGameDirs()
when (gameDir.type) {
DirectoryType.GAME -> {
NativeConfig.setGameDirs(gameDirs.filter { it.type == DirectoryType.GAME }.toTypedArray())
}
DirectoryType.EXTERNAL_CONTENT -> {
removeExternalContentDir(gameDir.uriString)
}
}
getGameDirsAndExternalContent()
}
}
}
@ -177,15 +192,16 @@ class GamesViewModel : ViewModel() {
fun updateGameDirs() =
viewModelScope.launch {
withContext(Dispatchers.IO) {
NativeConfig.setGameDirs(_folders.value.toTypedArray())
getGameDirs()
val gameDirs = _folders.value.filter { it.type == DirectoryType.GAME }
NativeConfig.setGameDirs(gameDirs.toTypedArray())
getGameDirsAndExternalContent()
}
}
fun onOpenGameFoldersFragment() =
viewModelScope.launch {
withContext(Dispatchers.IO) {
getGameDirs()
getGameDirsAndExternalContent()
}
}
@ -193,16 +209,36 @@ class GamesViewModel : ViewModel() {
NativeConfig.saveGlobalConfig()
viewModelScope.launch {
withContext(Dispatchers.IO) {
getGameDirs(true)
getGameDirsAndExternalContent(true)
}
}
}
private fun getGameDirs(reloadList: Boolean = false) {
val gameDirs = NativeConfig.getGameDirs()
_folders.value = gameDirs.toMutableList()
private fun getGameDirsAndExternalContent(reloadList: Boolean = false) {
val gameDirs = NativeConfig.getGameDirs().toMutableList()
val externalContentDirs = NativeConfig.getExternalContentDirs().map {
GameDir(it, false, DirectoryType.EXTERNAL_CONTENT)
}
gameDirs.addAll(externalContentDirs)
_folders.value = gameDirs
if (reloadList) {
reloadGames(true)
}
}
private fun addExternalContentDir(path: String) {
val currentDirs = NativeConfig.getExternalContentDirs().toMutableList()
if (!currentDirs.contains(path)) {
currentDirs.add(path)
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
NativeConfig.saveGlobalConfig()
}
}
private fun removeExternalContentDir(path: String) {
val currentDirs = NativeConfig.getExternalContentDirs().toMutableList()
currentDirs.remove(path)
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
NativeConfig.saveGlobalConfig()
}
}

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@ -12,5 +15,6 @@ data class Patch(
val version: String,
val type: Int,
val programId: String,
val titleId: String
val titleId: String,
val numericVersion: Long = 0
)

View File

@ -63,6 +63,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.documentfile.provider.DocumentFile
class MainActivity : AppCompatActivity(), ThemeProvider {
private lateinit var binding: ActivityMainBinding
@ -389,6 +390,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 +418,27 @@ 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 folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString }
if (folder != null) {
Toast.makeText(
applicationContext,
R.string.folder_already_added,
Toast.LENGTH_SHORT
).show()
return
}
val externalContentDir = org.yuzu.yuzu_emu.model.GameDir(uriString, false, org.yuzu.yuzu_emu.model.DirectoryType.EXTERNAL_CONTENT)
gamesViewModel.addFolder(externalContentDir, savedFromGameFragment = false)
}
val getProdKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result != null) {
processKey(result, "keys")

View File

@ -1,9 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-FileCopyrightText: 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.utils
import android.content.SharedPreferences
@ -49,6 +49,17 @@ object GameHelper {
// Remove previous filesystem provider information so we can get up to date version info
NativeLibrary.clearFilesystemProvider()
// Scan External Content directories and register all NSP/XCI files
val externalContentDirs = NativeConfig.getExternalContentDirs()
for (externalDir in externalContentDirs) {
if (externalDir.isNotEmpty()) {
val externalDirUri = externalDir.toUri()
if (FileUtil.isTreeUriValid(externalDirUri)) {
scanExternalContentRecursive(FileUtil.listFiles(externalDirUri), 3)
}
}
}
val badDirs = mutableListOf<Int>()
gameDirs.forEachIndexed { index: Int, gameDir: GameDir ->
val gameDirUri = gameDir.uriString.toUri()
@ -88,6 +99,33 @@ object GameHelper {
return games.toList()
}
// File extensions considered as external content, buuut should
// be done better imo.
private val externalContentExtensions = setOf("nsp", "xci")
private fun scanExternalContentRecursive(
files: Array<MinimalDocumentFile>,
depth: Int
) {
if (depth <= 0) {
return
}
files.forEach {
if (it.isDirectory) {
scanExternalContentRecursive(
FileUtil.listFiles(it.uri),
depth - 1
)
} else {
val extension = FileUtil.getExtension(it.uri).lowercase()
if (externalContentExtensions.contains(extension)) {
NativeLibrary.addFileToFilesystemProvider(it.uri.toString())
}
}
}
}
private fun addGamesRecursive(
games: MutableList<Game>,
files: Array<MinimalDocumentFile>,

View File

@ -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
@ -204,4 +204,12 @@ object NativeConfig {
external fun getSdmcDir(): String
@Synchronized
external fun setSdmcDir(path: String)
/**
* External Content Provider
*/
@Synchronized
external fun getExternalContentDirs(): Array<String>
@Synchronized
external fun setExternalContentDirs(dirs: Array<String>)
}

View File

@ -1,8 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include <common/fs/path_util.h>
#include <common/logging/log.h>
#include <common/settings.h>
#include <input_common/main.h>
#include "android_config.h"
#include "android_settings.h"
@ -69,6 +70,18 @@ void AndroidConfig::ReadPathValues() {
}
EndArray();
// Read external content directories
Settings::values.external_content_dirs.clear();
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();
const auto nand_dir_setting = ReadStringSetting(std::string("nand_directory"));
if (!nand_dir_setting.empty()) {
Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting);
@ -241,6 +254,14 @@ void AndroidConfig::SavePathValues() {
}
EndArray();
// Save external content directories
BeginArray(std::string("external_content_dirs"));
for (size_t i = 0; i < Settings::values.external_content_dirs.size(); ++i) {
SetArrayIndex(i);
WriteStringSetting(std::string("path"), Settings::values.external_content_dirs[i]);
}
EndArray();
// Save custom NAND directory
const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir);
WriteStringSetting(std::string("nand_directory"), nand_path,

View File

@ -55,8 +55,11 @@
#include "core/crypto/key_manager.h"
#include "core/file_sys/card_image.h"
#include "core/file_sys/content_archive.h"
#include "core/file_sys/control_metadata.h"
#include "core/file_sys/fs_filesystem.h"
#include "core/file_sys/romfs.h"
#include "core/file_sys/nca_metadata.h"
#include "core/file_sys/romfs.h"
#include "core/file_sys/submission_package.h"
#include "core/file_sys/vfs/vfs.h"
#include "core/file_sys/vfs/vfs_real.h"
@ -212,6 +215,109 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
return;
}
const auto extension = Common::ToLower(filepath.substr(filepath.find_last_of('.') + 1));
if (extension == "nsp") {
auto nsp = std::make_shared<FileSys::NSP>(file);
if (nsp->GetStatus() == Loader::ResultStatus::Success) {
std::map<u64, u32> nsp_versions;
std::map<u64, std::string> nsp_version_strings;
for (const auto& [title_id, nca_map] : nsp->GetNCAs()) {
for (const auto& [type_pair, nca] : nca_map) {
const auto& [title_type, content_type] = type_pair;
if (content_type == FileSys::ContentRecordType::Meta) {
const auto meta_nca = std::make_shared<FileSys::NCA>(nca->GetBaseFile());
if (meta_nca->GetStatus() == Loader::ResultStatus::Success) {
const auto section0 = meta_nca->GetSubdirectories();
if (!section0.empty()) {
for (const auto& meta_file : section0[0]->GetFiles()) {
if (meta_file->GetExtension() == "cnmt") {
FileSys::CNMT cnmt(meta_file);
nsp_versions[cnmt.GetTitleID()] = cnmt.GetTitleVersion();
}
}
}
}
}
if (content_type == FileSys::ContentRecordType::Control &&
title_type == FileSys::TitleType::Update) {
auto romfs = nca->GetRomFS();
if (romfs) {
auto extracted = FileSys::ExtractRomFS(romfs);
if (extracted) {
auto nacp_file = extracted->GetFile("control.nacp");
if (!nacp_file) {
nacp_file = extracted->GetFile("Control.nacp");
}
if (nacp_file) {
FileSys::NACP nacp(nacp_file);
auto ver_str = nacp.GetVersionString();
if (!ver_str.empty()) {
nsp_version_strings[title_id] = ver_str;
}
}
}
}
}
}
}
for (const auto& [title_id, nca_map] : nsp->GetNCAs()) {
for (const auto& [type_pair, nca] : nca_map) {
const auto& [title_type, content_type] = type_pair;
if (title_type == FileSys::TitleType::Update) {
u32 version = 0;
auto ver_it = nsp_versions.find(title_id);
if (ver_it != nsp_versions.end()) {
version = ver_it->second;
}
std::string version_string;
auto str_it = nsp_version_strings.find(title_id);
if (str_it != nsp_version_strings.end()) {
version_string = str_it->second;
}
m_manual_provider->AddEntryWithVersion(
title_type, content_type, title_id, version, version_string,
nca->GetBaseFile());
LOG_DEBUG(Frontend, "Added NSP update entry - TitleID: {:016X}, Version: {}, VersionStr: {}",
title_id, version, version_string);
} else {
// Use regular AddEntry for non-updates
m_manual_provider->AddEntry(title_type, content_type, title_id,
nca->GetBaseFile());
LOG_DEBUG(Frontend, "Added NSP entry - TitleID: {:016X}, TitleType: {}, ContentType: {}",
title_id, static_cast<int>(title_type), static_cast<int>(content_type));
}
}
}
return;
}
}
// Handle XCI files
if (extension == "xci") {
FileSys::XCI xci{file};
if (xci.GetStatus() == Loader::ResultStatus::Success) {
const auto nsp = xci.GetSecurePartitionNSP();
if (nsp) {
for (const auto& title : nsp->GetNCAs()) {
for (const auto& entry : title.second) {
m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first,
entry.second->GetBaseFile());
}
}
}
return;
}
}
auto loader = Loader::GetLoader(m_system, file);
if (!loader) {
return;
@ -228,17 +334,6 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
m_manual_provider->AddEntry(FileSys::TitleType::Application,
FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()),
program_id, file);
} else if (res2 == Loader::ResultStatus::Success &&
(file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) {
const auto nsp = file_type == Loader::FileType::NSP
? std::make_shared<FileSys::NSP>(file)
: FileSys::XCI{file}.GetSecurePartitionNSP();
for (const auto& title : nsp->GetNCAs()) {
for (const auto& entry : title.second) {
m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first,
entry.second->GetBaseFile());
}
}
}
}
@ -1333,7 +1428,8 @@ jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPatchesForFile(JNIEnv* env
Common::Android::ToJString(env, patch.name),
Common::Android::ToJString(env, patch.version), static_cast<jint>(patch.type),
Common::Android::ToJString(env, std::to_string(patch.program_id)),
Common::Android::ToJString(env, std::to_string(patch.title_id)));
Common::Android::ToJString(env, std::to_string(patch.title_id)),
static_cast<jlong>(patch.numeric_version));
env->SetObjectArrayElement(jpatchArray, i, jpatch);
++i;
}

View File

@ -583,4 +583,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<jstring>(env->GetObjectArrayElement(jdirs, i));
Settings::values.external_content_dirs.push_back(Common::Android::GetJString(env, jdir));
}
}
} // extern "C"

View File

@ -11,7 +11,7 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:orientation="vertical"
android:padding="16dp"
android:layout_gravity="center_vertical">
@ -23,12 +23,25 @@
android:layout_gravity="center_vertical|start"
android:requiresFadingEdge="horizontal"
android:textAlignment="viewStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/type_indicator"
app:layout_constraintEnd_toStartOf="@+id/button_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@string/select_gpu_driver_default" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/type_indicator"
style="@style/TextAppearance.Material3.LabelSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/path"
tools:text="Games" />
<LinearLayout
android:id="@+id/button_layout"
android:layout_width="wrap_content"

View File

@ -1774,4 +1774,8 @@ 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.
</string>
<string name="external_content">External Content</string>
<string name="add_folders">Add Folder</string>
</resources>

View File

@ -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 <jni.h>
@ -516,7 +516,7 @@ namespace Common::Android {
s_patch_class = reinterpret_cast<jclass>(env->NewGlobalRef(patch_class));
s_patch_constructor = env->GetMethodID(
patch_class, "<init>",
"(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V");
"(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;J)V");
s_patch_enabled_field = env->GetFieldID(patch_class, "enabled", "Z");
s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;");
s_patch_version_field = env->GetFieldID(patch_class, "version", "Ljava/lang/String;");

View File

@ -715,6 +715,7 @@ struct Values {
Category::DataStorage};
Setting<std::string> gamecard_path{linkage, std::string(), "gamecard_path",
Category::DataStorage};
std::vector<std::string> external_content_dirs;
// Debugging
bool record_frame_times;

View File

@ -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,127 @@ 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();
bool update_disabled = true;
std::optional<u32> enabled_version;
bool checked_external = false;
bool checked_manual = false;
const auto* content_union = dynamic_cast<const ContentProviderUnion*>(&content_provider);
const auto update_tid = GetUpdateTitleID(title_id);
if (content_union) {
// First, check ExternalContentProvider
const auto* external_provider = content_union->GetExternalProvider();
if (external_provider) {
const auto update_versions = external_provider->ListUpdateVersions(update_tid);
if (!update_versions.empty()) {
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;
}
}
}
}
// Also check ManualContentProvider (for Android)
if (!checked_external) {
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
if (manual_provider) {
const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid);
if (!manual_update_versions.empty()) {
checked_manual = true;
for (const auto& update_entry : manual_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;
}
}
}
}
}
}
// check for original NAND style
// Check NAND if: no external updates exist, OR all external updates are disabled
if (!checked_external && !checked_manual) {
// Only enable NAND update if it exists AND is not disabled
// We need to check if an update actually exists in the content provider
const bool has_nand_update = content_provider.HasEntry(update_tid, ContentRecordType::Program);
if (has_nand_update) {
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
const bool generic_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
if (!nand_disabled && !sdmc_disabled && !generic_disabled) {
update_disabled = false;
}
}
} else if (update_disabled && content_union) {
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
if (!nand_disabled || !sdmc_disabled) {
const auto nand_sdmc_entries = content_union->ListEntriesFilterOrigin(
std::nullopt, TitleType::Update, ContentRecordType::Program, update_tid);
for (const auto& [slot, entry] : nand_sdmc_entries) {
if (slot == ContentProviderUnionSlot::UserNAND ||
slot == ContentProviderUnionSlot::SysNAND) {
if (!nand_disabled) {
update_disabled = false;
break;
}
} else if (slot == ContentProviderUnionSlot::SDMC) {
if (!sdmc_disabled) {
update_disabled = false;
break;
}
}
}
}
}
// Game Updates
const auto update_tid = GetUpdateTitleID(title_id);
const auto update = content_provider.GetEntry(update_tid, ContentRecordType::Program);
std::unique_ptr<NCA> 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<NCA>(file);
}
}
// Also try ManualContentProvider
if (update == nullptr) {
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
if (manual_provider) {
auto file = manual_provider->GetEntryForVersion(update_tid, ContentRecordType::Program, *enabled_version);
if (file != nullptr) {
update = std::make_unique<NCA>(file);
}
}
}
}
// Fallback to regular content provider if no external update was loaded
if (update == nullptr && !update_disabled) {
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 +562,103 @@ 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<u32> enabled_version;
VirtualFile update_raw = nullptr;
bool checked_external = false;
bool checked_manual = false;
const auto* content_union = dynamic_cast<const ContentProviderUnion*>(&content_provider);
if (content_union) {
// First, check ExternalContentProvider
const auto* external_provider = content_union->GetExternalProvider();
if (external_provider) {
const auto update_versions = external_provider->ListUpdateVersions(update_tid);
if (!update_versions.empty()) {
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;
}
}
}
}
if (!checked_external) {
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
if (manual_provider) {
const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid);
if (!manual_update_versions.empty()) {
checked_manual = true;
for (const auto& update_entry : manual_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 = manual_provider->GetEntryForVersion(update_tid, type, update_entry.version);
break;
}
}
}
}
}
}
if (!checked_external && !checked_manual) {
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
const bool generic_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
if (!nand_disabled && !sdmc_disabled && !generic_disabled) {
update_disabled = false;
}
if (!update_disabled) {
update_raw = content_provider.GetEntryRaw(update_tid, type);
}
} else if (update_disabled && content_union) {
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
if (!nand_disabled || !sdmc_disabled) {
const auto nand_sdmc_entries = content_union->ListEntriesFilterOrigin(
std::nullopt, TitleType::Update, type, update_tid);
for (const auto& [slot, entry] : nand_sdmc_entries) {
if (slot == ContentProviderUnionSlot::UserNAND ||
slot == ContentProviderUnionSlot::SysNAND) {
if (!nand_disabled) {
update_disabled = false;
update_raw = content_provider.GetEntryRaw(update_tid, type);
break;
}
} else if (slot == ContentProviderUnionSlot::SDMC) {
if (!sdmc_disabled) {
update_disabled = false;
update_raw = content_provider.GetEntryRaw(update_tid, type);
break;
}
}
}
}
}
if (!update_disabled && update_raw != nullptr && base_nca != nullptr) {
const auto new_nca = std::make_shared<NCA>(update_raw, base_nca);
if (new_nca->GetStatus() == Loader::ResultStatus::Success &&
new_nca->GetRomFS() != nullptr) {
LOG_INFO(Loader, " RomFS: Update ({}) applied successfully",
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<NCA>(packed_update_raw, base_nca);
@ -490,18 +687,160 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
// Game Updates
const auto update_tid = GetUpdateTitleID(title_id);
std::vector<Patch> external_update_patches;
const auto* content_union = dynamic_cast<const ContentProviderUnion*>(&content_provider);
if (content_union) {
// First, check ExternalContentProvider for updates
const auto* external_provider = content_union->GetExternalProvider();
if (external_provider) {
const auto update_versions = external_provider->ListUpdateVersions(update_tid);
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 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 = "Update",
.version = version_str,
.type = PatchType::Update,
.program_id = title_id,
.title_id = update_tid,
.source = PatchSource::External,
.numeric_version = update_entry.version};
external_update_patches.push_back(update_patch);
}
}
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
if (manual_provider && external_update_patches.empty()) {
const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid);
for (const auto& update_entry : manual_update_versions) {
std::string version_str = update_entry.version_string;
if (version_str.empty()) {
version_str = FormatTitleVersion(update_entry.version);
}
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 = "Update",
.version = version_str,
.type = PatchType::Update,
.program_id = title_id,
.title_id = update_tid,
.source = PatchSource::External,
.numeric_version = update_entry.version};
external_update_patches.push_back(update_patch);
}
}
if (external_update_patches.size() > 1) {
bool found_enabled = false;
for (auto& patch : external_update_patches) {
if (patch.enabled) {
if (found_enabled) {
patch.enabled = false;
} else {
found_enabled = true;
}
}
}
}
for (auto& patch : external_update_patches) {
out.push_back(std::move(patch));
}
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;
const auto update_disabled =
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;
bool 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;
bool 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};
.title_id = title_id,
.source = PatchSource::Unknown,
.numeric_version = 0};
if (nacp != nullptr) {
update_patch.version = nacp->GetVersionString();
@ -513,13 +852,16 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
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)
const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id);
@ -533,7 +875,8 @@ std::vector<Patch> 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 +922,8 @@ std::vector<Patch> 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,21 +947,44 @@ std::vector<Patch> 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});
}
}
// DLC
const auto dlc_entries =
content_provider.ListEntriesFilter(TitleType::AOC, ContentRecordType::Data);
std::vector<ContentProviderEntry> dlc_match;
dlc_match.reserve(dlc_entries.size());
std::copy_if(dlc_entries.begin(), dlc_entries.end(), std::back_inserter(dlc_match),
[this](const ContentProviderEntry& entry) {
return GetBaseTitleID(entry.title_id) == title_id &&
content_provider.GetEntry(entry)->GetStatus() ==
Loader::ResultStatus::Success;
const auto base_tid = GetBaseTitleID(entry.title_id);
const bool matches_base = base_tid == title_id;
if (!matches_base) {
LOG_DEBUG(Loader, "DLC {:016X} base {:016X} doesn't match title {:016X}",
entry.title_id, base_tid, title_id);
return false;
}
auto nca = content_provider.GetEntry(entry);
if (!nca) {
LOG_DEBUG(Loader, "Failed to get NCA for DLC {:016X}", entry.title_id);
return false;
}
const auto status = nca->GetStatus();
if (status != Loader::ResultStatus::Success) {
LOG_DEBUG(Loader, "DLC {:016X} NCA has status {}",
entry.title_id, static_cast<int>(status));
return false;
}
return true;
});
if (!dlc_match.empty()) {
// Ensure sorted so DLC IDs show in order.
std::sort(dlc_match.begin(), dlc_match.end());
@ -635,7 +1002,8 @@ std::vector<Patch> 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;

View File

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

View File

@ -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,22 @@ std::optional<ContentProviderUnionSlot> 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<const ExternalContentProvider*>(it->second);
}
return nullptr;
}
const ContentProvider* ContentProviderUnion::GetSlotProvider(ContentProviderUnionSlot slot) const {
auto it = providers.find(slot);
if (it != providers.end()) {
return it->second;
}
return nullptr;
}
ManualContentProvider::~ManualContentProvider() = default;
void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType content_type,
@ -981,8 +1000,51 @@ void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType con
entries.insert_or_assign({title_type, content_type, title_id}, file);
}
void ManualContentProvider::AddEntryWithVersion(TitleType title_type, ContentRecordType content_type,
u64 title_id, u32 version,
const std::string& version_string, VirtualFile file) {
if (title_type == TitleType::Update) {
auto it = std::find_if(multi_version_entries.begin(), multi_version_entries.end(),
[title_id, version](const ExternalUpdateEntry& entry) {
return entry.title_id == title_id && entry.version == version;
});
if (it != multi_version_entries.end()) {
// Update existing entry
it->files[content_type] = file;
if (!version_string.empty()) {
it->version_string = version_string;
}
} else {
// Add new entry
ExternalUpdateEntry new_entry;
new_entry.title_id = title_id;
new_entry.version = version;
new_entry.version_string = version_string;
new_entry.files[content_type] = file;
multi_version_entries.push_back(new_entry);
}
auto existing = entries.find({title_type, content_type, title_id});
if (existing == entries.end()) {
entries.insert_or_assign({title_type, content_type, title_id}, file);
} else {
// Check if this version is higher
for (const auto& entry : multi_version_entries) {
if (entry.title_id == title_id && entry.version > version) {
return; // Don't replace with lower version
}
}
entries.insert_or_assign({title_type, content_type, title_id}, file);
}
} else {
entries.insert_or_assign({title_type, content_type, title_id}, file);
}
}
void ManualContentProvider::ClearAllEntries() {
entries.clear();
multi_version_entries.clear();
}
void ManualContentProvider::Refresh() {}
@ -1036,4 +1098,459 @@ std::vector<ContentProviderEntry> ManualContentProvider::ListEntriesFilter(
out.erase(std::unique(out.begin(), out.end()), out.end());
return out;
}
std::vector<ExternalUpdateEntry> ManualContentProvider::ListUpdateVersions(u64 title_id) const {
std::vector<ExternalUpdateEntry> 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 ManualContentProvider::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 ManualContentProvider::HasMultipleVersions(u64 title_id, ContentRecordType type) const {
int 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;
}
ExternalContentProvider::ExternalContentProvider(std::vector<VirtualDir> 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;
}
LOG_DEBUG(Service_FS, "Processing NSP file: {}", file->GetName());
const auto ncas = nsp.GetNCAs();
std::map<u64, u32> nsp_versions;
std::map<u64, std::string> 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::pair<u64, u32>, std::map<ContentRecordType, VirtualFile>> 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<int>(title_type), static_cast<int>(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<u64, u32> xci_versions;
std::map<u64, std::string> 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::pair<u64, u32>, std::map<ContentRecordType, VirtualFile>> 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<u32> 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<NCA> ExternalContentProvider::GetEntry(u64 title_id,
ContentRecordType type) const {
const auto file = GetEntryRaw(title_id, type);
if (file == nullptr) {
return nullptr;
}
return std::make_unique<NCA>(file);
}
std::vector<ContentProviderEntry> ExternalContentProvider::ListEntriesFilter(
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
std::optional<u64> title_id) const {
std::vector<ContentProviderEntry> 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<ExternalUpdateEntry> ExternalContentProvider::ListUpdateVersions(u64 title_id) const {
std::vector<ExternalUpdateEntry> 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

View File

@ -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<ContentRecordType, VirtualFile> 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,9 @@ public:
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
std::optional<u64> title_id) const override;
const ExternalContentProvider* GetExternalProvider() const;
const ContentProvider* GetSlotProvider(ContentProviderUnionSlot slot) const;
std::vector<std::pair<ContentProviderUnionSlot, ContentProviderEntry>> ListEntriesFilterOrigin(
std::optional<ContentProviderUnionSlot> origin = {},
std::optional<TitleType> title_type = {}, std::optional<ContentRecordType> record_type = {},
@ -246,6 +261,8 @@ public:
void AddEntry(TitleType title_type, ContentRecordType content_type, u64 title_id,
VirtualFile file);
void AddEntryWithVersion(TitleType title_type, ContentRecordType content_type, u64 title_id,
u32 version, const std::string& version_string, VirtualFile file);
void ClearAllEntries();
void Refresh() override;
@ -258,8 +275,46 @@ public:
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
std::optional<u64> title_id) const override;
std::vector<ExternalUpdateEntry> ListUpdateVersions(u64 title_id) const;
VirtualFile GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const;
bool HasMultipleVersions(u64 title_id, ContentRecordType type) const;
private:
std::map<std::tuple<TitleType, ContentRecordType, u64>, VirtualFile> entries;
std::vector<ExternalUpdateEntry> multi_version_entries;
};
class ExternalContentProvider : public ContentProvider {
public:
explicit ExternalContentProvider(std::vector<VirtualDir> load_directories = {});
~ExternalContentProvider() override;
void AddDirectory(VirtualDir directory);
void ClearDirectories();
void Refresh() override;
bool HasEntry(u64 title_id, ContentRecordType type) const override;
std::optional<u32> 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<NCA> GetEntry(u64 title_id, ContentRecordType type) const override;
std::vector<ContentProviderEntry> ListEntriesFilter(
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
std::optional<u64> title_id) const override;
std::vector<ExternalUpdateEntry> 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<VirtualDir> load_dirs;
std::map<std::tuple<u64, ContentRecordType, TitleType>, VirtualFile> entries;
std::map<u64, u32> versions;
std::vector<ExternalUpdateEntry> multi_version_entries;
};
} // namespace FileSys

View File

@ -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
@ -275,6 +278,14 @@ void NSP::ReadNCAs(const std::vector<VirtualFile>& files) {
ncas[next_nca->GetTitleId()][{cnmt.GetType(), rec.type}] =
std::move(next_nca);
} else {
// fix for Bayonetta Origins in Bayonetta 3 and external content
// where multiple update NCAs exist for the same title and type.
auto& target_map = ncas[cnmt.GetTitleID()];
auto existing = target_map.find({cnmt.GetType(), rec.type});
if (existing != target_map.end() && rec.type == ContentRecordType::Program) {
continue;
}
ncas[cnmt.GetTitleID()][{cnmt.GetType(), rec.type}] = std::move(next_nca);
}
} else {

View File

@ -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");
@ -684,6 +689,7 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove
if (overwrite) {
bis_factory = nullptr;
sdmc_factory = nullptr;
external_provider = nullptr;
}
using EdenPath = Common::FS::EdenPath;
@ -716,6 +722,36 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove
system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC,
sdmc_factory->GetSDMCContents());
}
if (external_provider == nullptr) {
std::vector<FileSys::VirtualDir> 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<FileSys::ExternalContentProvider>(
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() {

View File

@ -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<FileSys::SDMCFactory> sdmc_factory;
std::unique_ptr<FileSys::BISFactory> bis_factory;
std::unique_ptr<FileSys::ExternalContentProvider> external_provider;
std::unique_ptr<FileSys::XCI> gamecard;
std::unique_ptr<FileSys::RegisteredCache> gamecard_registered;
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;

View File

@ -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<int>(Settings::values.external_content_dirs.size()); ++i) {
SetArrayIndex(i);
WriteStringSetting(std::string("path"), Settings::values.external_content_dirs[i]);
}
EndArray();
EndGroup();
}

View File

@ -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 "qt_common/util/game.h"
@ -373,27 +373,23 @@ void RemoveCacheStorage(u64 program_id)
}
// Metadata //
void ResetMetadata(bool show_message)
{
void ResetMetadata(bool show_message) {
const QString title = tr("Reset Metadata Cache");
if (!Common::FS::Exists(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir)
/ "game_list/")) {
if (!Common::FS::Exists(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) /
"game_list/")) {
if (show_message)
QtCommon::Frontend::Warning(rootObject,
title,
QtCommon::Frontend::Warning(rootObject, title,
tr("The metadata cache is already empty."));
} else if (Common::FS::RemoveDirRecursively(
Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "game_list")) {
if (show_message)
QtCommon::Frontend::Information(rootObject,
title,
QtCommon::Frontend::Information(rootObject, title,
tr("The operation completed successfully."));
UISettings::values.is_game_list_reload_pending.exchange(true);
} else {
if (show_message)
QtCommon::Frontend::Warning(
title,
tr("The metadata cache couldn't be deleted. It might be in use or non-existent."));
}

View File

@ -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
@ -99,6 +99,8 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_,
}
});
connect(ui_tab.get(), &ConfigureUi::LanguageChanged, this, &ConfigureDialog::OnLanguageChanged);
connect(general_tab.get(), &ConfigureGeneral::ExternalContentDirsChanged, this,
&ConfigureDialog::ExternalContentDirsChanged);
connect(ui->selectorList, &QListWidget::itemSelectionChanged, this,
&ConfigureDialog::UpdateVisibleTabs);

View File

@ -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
@ -62,6 +62,7 @@ private slots:
signals:
void LanguageChanged(const QString& locale);
void ExternalContentDirsChanged();
private:
void changeEvent(QEvent* event) override;

View File

@ -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,9 +38,9 @@ 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);
}
@ -278,6 +278,7 @@ void ConfigureFilesystem::UpdateEnabledControls() {
!ui->gamecard_current_game->isChecked());
}
void ConfigureFilesystem::RetranslateUI() {
ui->retranslateUi(this);
}

View File

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

View File

@ -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
@ -7,6 +7,9 @@
#include <functional>
#include <utility>
#include <vector>
#include <QDir>
#include <QFileDialog>
#include <QListWidget>
#include <QMessageBox>
#include "common/settings.h"
#include "core/core.h"
@ -29,6 +32,15 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
connect(ui->button_reset_defaults, &QPushButton::clicked, this,
&ConfigureGeneral::ResetDefaults);
connect(ui->add_external_dir_button, &QPushButton::pressed, this,
&ConfigureGeneral::AddExternalContentDirectory);
connect(ui->remove_external_dir_button, &QPushButton::pressed, this,
&ConfigureGeneral::RemoveSelectedExternalContentDirectory);
connect(ui->external_content_list, &QListWidget::itemSelectionChanged, this, [this] {
ui->remove_external_dir_button->setEnabled(
!ui->external_content_list->selectedItems().isEmpty());
});
if (!Settings::IsConfiguringGlobal()) {
ui->button_reset_defaults->setVisible(false);
}
@ -36,7 +48,9 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
ConfigureGeneral::~ConfigureGeneral() = default;
void ConfigureGeneral::SetConfiguration() {}
void ConfigureGeneral::SetConfiguration() {
UpdateExternalContentList();
}
void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) {
QLayout& general_layout = *ui->general_widget->layout();
@ -101,6 +115,55 @@ void ConfigureGeneral::ApplyConfiguration() {
for (const auto& func : apply_funcs) {
func(powered_on);
}
std::vector<std::string> new_dirs;
new_dirs.reserve(ui->external_content_list->count());
for (int i = 0; i < ui->external_content_list->count(); ++i) {
new_dirs.push_back(ui->external_content_list->item(i)->text().toStdString());
}
if (new_dirs != Settings::values.external_content_dirs) {
Settings::values.external_content_dirs = std::move(new_dirs);
emit ExternalContentDirsChanged();
}
}
void ConfigureGeneral::UpdateExternalContentList() {
ui->external_content_list->clear();
for (const auto& dir : Settings::values.external_content_dirs) {
ui->external_content_list->addItem(QString::fromStdString(dir));
}
}
void ConfigureGeneral::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 ConfigureGeneral::RemoveSelectedExternalContentDirectory() {
auto selected = ui->external_content_list->selectedItems();
if (!selected.isEmpty()) {
qDeleteAll(ui->external_content_list->selectedItems());
}
}
void ConfigureGeneral::changeEvent(QEvent* event) {

View File

@ -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
@ -39,12 +42,19 @@ public:
void ApplyConfiguration() override;
void SetConfiguration() override;
signals:
void ExternalContentDirsChanged();
private:
void Setup(const ConfigurationShared::Builder& builder);
void changeEvent(QEvent* event) override;
void RetranslateUI();
void UpdateExternalContentList();
void AddExternalContentDirectory();
void RemoveSelectedExternalContentDirectory();
std::function<void()> reset_callback;
std::unique_ptr<Ui::ConfigureGeneral> ui;

View File

@ -46,6 +46,66 @@
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_external">
<property name="title">
<string>External Content</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_external">
<item>
<widget class="QLabel" name="label_external_desc">
<property name="text">
<string>Add directories to scan for DLCs and Updates without installing to NAND</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="external_content_list">
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_external_buttons">
<item>
<widget class="QPushButton" name="add_external_dir_button">
<property name="text">
<string>Add Directory</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="remove_external_dir_button">
<property name="text">
<string>Remove Selected</string>
</property>
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_external">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">

View File

@ -8,6 +8,8 @@
#include <memory>
#include <utility>
#include <fmt/format.h>
#include <QHeaderView>
#include <QMenu>
#include <QStandardItemModel>
@ -16,6 +18,7 @@
#include <QTreeView>
#include <qstandardpaths.h>
#include "common/common_types.h"
#include "common/fs/fs.h"
#include "common/fs/path_util.h"
#include "configuration/addon/mod_select_dialog.h"
@ -68,6 +71,8 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
ui->scrollArea->setEnabled(!system.IsPoweredOn());
connect(item_model, &QStandardItemModel::itemChanged, this,
&ConfigurePerGameAddons::OnItemChanged);
connect(item_model, &QStandardItemModel::itemChanged,
[] { UISettings::values.is_game_list_reload_pending.exchange(true); });
@ -77,14 +82,38 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
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<std::string> disabled_addons;
for (const auto& item : list_items) {
const auto disabled = item.front()->checkState() == Qt::Unchecked;
if (disabled)
if (disabled) {
QVariant userData = item.front()->data(Qt::UserRole);
if (userData.isValid() && userData.canConvert<quint32>() && 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];
std::sort(disabled_addons.begin(), disabled_addons.end());
@ -194,17 +223,51 @@ 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<FileSys::Patch> patches = pm.GetPatches(update_raw);
bool has_enabled_update = false;
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();
const bool is_external_update = patch.type == FileSys::PatchType::Update &&
patch.source == FileSys::PatchSource::External &&
patch.numeric_version != 0;
first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked);
if (is_external_update) {
first_item->setData(static_cast<quint32>(patch.numeric_version), Qt::UserRole);
}
bool patch_disabled = false;
if (is_external_update) {
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 = 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<QStandardItem*>{
first_item, new QStandardItem{QString::fromStdString(patch.version)}});

View File

@ -54,6 +54,7 @@ private:
void RetranslateUI();
void LoadConfiguration();
void OnItemChanged(QStandardItem* item);
std::unique_ptr<Ui::ConfigurePerGameAddons> ui;
FileSys::VirtualFile file;
@ -64,6 +65,7 @@ private:
QStandardItemModel* item_model;
std::vector<QList<QStandardItem*>> list_items;
std::vector<QStandardItem*> update_items;
Core::System& system;
};

View File

@ -16,11 +16,13 @@
#include <QToolButton>
#include <QVariantAnimation>
#include <fmt/ranges.h>
#include <qfilesystemwatcher.h>
#include <qnamespace.h>
#include <qscroller.h>
#include <qscrollerproperties.h>
#include "common/common_types.h"
#include "common/logging/log.h"
#include "common/settings.h"
#include "core/core.h"
#include "core/file_sys/patch_manager.h"
#include "core/file_sys/registered_cache.h"
@ -32,6 +34,7 @@
#include "yuzu/game_list_worker.h"
#include "yuzu/main_window.h"
#include "yuzu/util/controller_navigation.h"
#include "qt_common/qt_common.h"
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent)
: QObject(parent), gamelist{gamelist_} {}
@ -325,6 +328,10 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
watcher = new QFileSystemWatcher(this);
connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory);
external_watcher = new QFileSystemWatcher(this);
ResetExternalWatcher();
connect(external_watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshExternalContent);
this->main_window = parent;
layout = new QVBoxLayout;
tree_view = new QTreeView;
@ -919,12 +926,38 @@ const QStringList GameList::supported_file_extensions = {
void GameList::RefreshGameDirectory()
{
// Reset the externals watcher whenever the game list is reloaded,
// primarily ensures that new titles and external dirs are caught.
ResetExternalWatcher();
if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) {
LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list.");
QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs);
PopulateAsync(UISettings::values.game_dirs);
}
}
void GameList::RefreshExternalContent() {
// TODO: Explore the possibility of only resetting the metadata cache for that specific game.
if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) {
LOG_INFO(Frontend, "External content directory changed. Clearing metadata cache.");
QtCommon::Game::ResetMetadata(false);
QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs);
PopulateAsync(UISettings::values.game_dirs);
}
}
void GameList::ResetExternalWatcher() {
auto watch_dirs = external_watcher->directories();
if (!watch_dirs.isEmpty()) {
external_watcher->removePaths(watch_dirs);
}
for (const std::string &dir : Settings::values.external_content_dirs) {
external_watcher->addPath(QString::fromStdString(dir));
}
}
void GameList::ToggleFavorite(u64 program_id) {
if (!UISettings::values.favorited_ids.contains(program_id)) {
tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(),

View File

@ -94,6 +94,8 @@ public:
public slots:
void RefreshGameDirectory();
void RefreshExternalContent();
void ResetExternalWatcher();
signals:
void BootGame(const QString& game_path, StartGameType type);
@ -160,6 +162,7 @@ private:
QStandardItemModel* item_model = nullptr;
std::unique_ptr<GameListWorker> current_worker;
QFileSystemWatcher* watcher = nullptr;
QFileSystemWatcher* external_watcher = nullptr;
ControllerNavigation* controller_navigation = nullptr;
CompatibilityList compatibility_list;

View File

@ -3388,6 +3388,8 @@ void MainWindow::OnConfigure() {
!multiplayer_state->IsHostingPublicRoom());
connect(&configure_dialog, &ConfigureDialog::LanguageChanged, this,
&MainWindow::OnLanguageChanged);
connect(&configure_dialog, &ConfigureDialog::ExternalContentDirsChanged, this,
&MainWindow::OnGameListRefresh);
const auto result = configure_dialog.exec();
if (result != QDialog::Accepted && !UISettings::values.configuration_applied &&
@ -3907,8 +3909,7 @@ void MainWindow::OnToggleStatusBar() {
statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked());
}
void MainWindow::OnGameListRefresh()
{
void MainWindow::OnGameListRefresh() {
// Resets metadata cache and reloads
QtCommon::Game::ResetMetadata(false);
game_list->RefreshGameDirectory();