[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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-FileCopyrightText: 2023 yuzu Emulator Project
@ -10,6 +10,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding
import org.yuzu.yuzu_emu.model.Patch 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.model.AddonViewModel
import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
@ -31,8 +32,13 @@ class AddonAdapter(val addonViewModel: AddonViewModel) :
binding.addonSwitch.isChecked = model.enabled binding.addonSwitch.isChecked = model.enabled
binding.addonSwitch.setOnCheckedChangeListener { _, checked -> binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
if (PatchType.from(model.type) == PatchType.Update && checked) {
addonViewModel.enableOnlyThisUpdate(model)
notifyDataSetChanged()
} else {
model.enabled = checked model.enabled = checked
} }
}
val deleteAction = { val deleteAction = {
addonViewModel.setAddonToDelete(model) 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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -7,8 +10,10 @@ import android.net.Uri
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.CardFolderBinding import org.yuzu.yuzu_emu.databinding.CardFolderBinding
import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment 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.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.ViewUtils.marquee 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.text = Uri.parse(model.uriString).path
path.marquee() 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 { buttonEdit.setOnClickListener {
GameFolderPropertiesDialogFragment.newInstance(model) GameFolderPropertiesDialogFragment.newInstance(model)
.show( .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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -6,11 +9,13 @@ package org.yuzu.yuzu_emu.fragments
import android.app.Dialog import android.app.Dialog
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding 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.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.NativeConfig
@ -25,15 +30,19 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) val binding = DialogFolderPropertiesBinding.inflate(layoutInflater)
val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!! 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 = binding.deepScanSwitch.isChecked =
savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan
// Ensure that we can get the checkbox state even if the view is destroyed
deepScan = binding.deepScanSwitch.isChecked deepScan = binding.deepScanSwitch.isChecked
binding.deepScanSwitch.setOnClickListener { binding.deepScanSwitch.setOnClickListener {
deepScan = binding.deepScanSwitch.isChecked deepScan = binding.deepScanSwitch.isChecked
} }
}
return MaterialAlertDialogBuilder(requireContext()) return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root) .setView(binding.root)
@ -41,8 +50,10 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) val folderIndex = gamesViewModel.folders.value.indexOf(gameDir)
if (folderIndex != -1) { if (folderIndex != -1) {
if (gameDir.type == DirectoryType.GAME) {
gamesViewModel.folders.value[folderIndex].deepScan = gamesViewModel.folders.value[folderIndex].deepScan =
binding.deepScanSwitch.isChecked binding.deepScanSwitch.isChecked
}
gamesViewModel.updateGameDirs() 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 // SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.fragments package org.yuzu.yuzu_emu.fragments
@ -15,11 +15,14 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.FolderAdapter import org.yuzu.yuzu_emu.adapters.FolderAdapter
import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding 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.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.ui.main.MainActivity import org.yuzu.yuzu_emu.ui.main.MainActivity
@ -73,8 +76,26 @@ class GameFoldersFragment : Fragment() {
val mainActivity = requireActivity() as MainActivity val mainActivity = requireActivity() as MainActivity
binding.buttonAdd.setOnClickListener { 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) mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
} }
1 -> { // External Content Folder
mainActivity.getExternalContentDirectory.launch(null)
}
}
}
.show()
}
setInsets() setInsets()
} }

View File

@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // 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 package org.yuzu.yuzu_emu.fragments
import android.Manifest 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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -48,16 +51,68 @@ class AddonViewModel : ViewModel() {
?: emptyArray() ?: emptyArray()
).toMutableList() ).toMutableList()
patchList.sortBy { it.name } patchList.sortBy { it.name }
// Ensure only one update is enabled
ensureSingleUpdateEnabled(patchList)
removeDuplicates(patchList)
_patchList.value = patchList _patchList.value = patchList
isRefreshing.set(false) 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?) { fun setAddonToDelete(patch: Patch?) {
_addonToDelete.value = 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) { fun onDeleteAddon(patch: Patch) {
when (PatchType.from(patch.type)) { when (PatchType.from(patch.type)) {
PatchType.Update -> NativeLibrary.removeUpdate(patch.programId) PatchType.Update -> NativeLibrary.removeUpdate(patch.programId)
@ -72,13 +127,27 @@ class AddonViewModel : ViewModel() {
return 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( NativeConfig.setDisabledAddons(
game!!.programId, game!!.programId,
_patchList.value.mapNotNull { _patchList.value.mapNotNull {
if (it.enabled) { if (it.enabled) {
null null
} else { } else {
if (PatchType.from(it.type) == PatchType.Update) {
if (it.name.contains("(NAND)") || it.name.contains("(SDMC)")) {
it.name it.name
} else if (hasMultipleUpdates) {
"Update@${it.numericVersion}"
} else {
it.name
}
} else {
it.name
}
} }
}.toTypedArray() }.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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -9,5 +12,14 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class GameDir( data class GameDir(
val uriString: String, val uriString: String,
var deepScan: Boolean var deepScan: Boolean,
) : Parcelable 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 // SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.model package org.yuzu.yuzu_emu.model
@ -56,7 +56,7 @@ class GamesViewModel : ViewModel() {
// Ensure keys are loaded so that ROM metadata can be decrypted. // Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys() NativeLibrary.reloadKeys()
getGameDirs() getGameDirsAndExternalContent()
reloadGames(directoriesChanged = false, firstStartup = true) reloadGames(directoriesChanged = false, firstStartup = true)
} }
@ -144,11 +144,19 @@ class GamesViewModel : ViewModel() {
fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) = fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) =
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
when (gameDir.type) {
DirectoryType.GAME -> {
NativeConfig.addGameDir(gameDir) NativeConfig.addGameDir(gameDir)
val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
.getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true) .getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true)
getGameDirsAndExternalContent(!isFirstTimeSetup)
getGameDirs(!isFirstTimeSetup) }
DirectoryType.EXTERNAL_CONTENT -> {
addExternalContentDir(gameDir.uriString)
NativeConfig.saveGlobalConfig()
getGameDirsAndExternalContent()
}
}
} }
if (savedFromGameFragment) { if (savedFromGameFragment) {
@ -168,8 +176,15 @@ class GamesViewModel : ViewModel() {
val removedDirIndex = gameDirs.indexOf(gameDir) val removedDirIndex = gameDirs.indexOf(gameDir)
if (removedDirIndex != -1) { if (removedDirIndex != -1) {
gameDirs.removeAt(removedDirIndex) gameDirs.removeAt(removedDirIndex)
NativeConfig.setGameDirs(gameDirs.toTypedArray()) when (gameDir.type) {
getGameDirs() 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() = fun updateGameDirs() =
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
NativeConfig.setGameDirs(_folders.value.toTypedArray()) val gameDirs = _folders.value.filter { it.type == DirectoryType.GAME }
getGameDirs() NativeConfig.setGameDirs(gameDirs.toTypedArray())
getGameDirsAndExternalContent()
} }
} }
fun onOpenGameFoldersFragment() = fun onOpenGameFoldersFragment() =
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
getGameDirs() getGameDirsAndExternalContent()
} }
} }
@ -193,16 +209,36 @@ class GamesViewModel : ViewModel() {
NativeConfig.saveGlobalConfig() NativeConfig.saveGlobalConfig()
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
getGameDirs(true) getGameDirsAndExternalContent(true)
} }
} }
} }
private fun getGameDirs(reloadList: Boolean = false) { private fun getGameDirsAndExternalContent(reloadList: Boolean = false) {
val gameDirs = NativeConfig.getGameDirs() val gameDirs = NativeConfig.getGameDirs().toMutableList()
_folders.value = gameDirs.toMutableList() val externalContentDirs = NativeConfig.getExternalContentDirs().map {
GameDir(it, false, DirectoryType.EXTERNAL_CONTENT)
}
gameDirs.addAll(externalContentDirs)
_folders.value = gameDirs
if (reloadList) { if (reloadList) {
reloadGames(true) 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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -12,5 +15,6 @@ data class Patch(
val version: String, val version: String,
val type: Int, val type: Int,
val programId: String, 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.documentfile.provider.DocumentFile
class MainActivity : AppCompatActivity(), ThemeProvider { class MainActivity : AppCompatActivity(), ThemeProvider {
private lateinit var binding: ActivityMainBinding 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) { fun processGamesDir(result: Uri, calledFromGameFragment: Boolean = false) {
contentResolver.takePersistableUriPermission( contentResolver.takePersistableUriPermission(
result, result,
@ -410,6 +418,27 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
.show(supportFragmentManager, AddGameFolderDialogFragment.TAG) .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 -> val getProdKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result != null) { if (result != null) {
processKey(result, "keys") 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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // 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 package org.yuzu.yuzu_emu.utils
import android.content.SharedPreferences import android.content.SharedPreferences
@ -49,6 +49,17 @@ object GameHelper {
// Remove previous filesystem provider information so we can get up to date version info // Remove previous filesystem provider information so we can get up to date version info
NativeLibrary.clearFilesystemProvider() 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>() val badDirs = mutableListOf<Int>()
gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> gameDirs.forEachIndexed { index: Int, gameDir: GameDir ->
val gameDirUri = gameDir.uriString.toUri() val gameDirUri = gameDir.uriString.toUri()
@ -88,6 +99,33 @@ object GameHelper {
return games.toList() 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( private fun addGamesRecursive(
games: MutableList<Game>, games: MutableList<Game>,
files: Array<MinimalDocumentFile>, 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-FileCopyrightText: 2023 yuzu Emulator Project
@ -204,4 +204,12 @@ object NativeConfig {
external fun getSdmcDir(): String external fun getSdmcDir(): String
@Synchronized @Synchronized
external fun setSdmcDir(path: String) 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 // SPDX-License-Identifier: GPL-3.0-or-later
#include <common/fs/path_util.h> #include <common/fs/path_util.h>
#include <common/logging/log.h> #include <common/logging/log.h>
#include <common/settings.h>
#include <input_common/main.h> #include <input_common/main.h>
#include "android_config.h" #include "android_config.h"
#include "android_settings.h" #include "android_settings.h"
@ -69,6 +70,18 @@ void AndroidConfig::ReadPathValues() {
} }
EndArray(); 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")); const auto nand_dir_setting = ReadStringSetting(std::string("nand_directory"));
if (!nand_dir_setting.empty()) { if (!nand_dir_setting.empty()) {
Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting); Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting);
@ -241,6 +254,14 @@ void AndroidConfig::SavePathValues() {
} }
EndArray(); 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 // Save custom NAND directory
const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir); const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir);
WriteStringSetting(std::string("nand_directory"), nand_path, WriteStringSetting(std::string("nand_directory"), nand_path,

View File

@ -55,8 +55,11 @@
#include "core/crypto/key_manager.h" #include "core/crypto/key_manager.h"
#include "core/file_sys/card_image.h" #include "core/file_sys/card_image.h"
#include "core/file_sys/content_archive.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/fs_filesystem.h"
#include "core/file_sys/romfs.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/submission_package.h"
#include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs.h"
#include "core/file_sys/vfs/vfs_real.h" #include "core/file_sys/vfs/vfs_real.h"
@ -212,6 +215,109 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
return; 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); auto loader = Loader::GetLoader(m_system, file);
if (!loader) { if (!loader) {
return; return;
@ -228,17 +334,6 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
m_manual_provider->AddEntry(FileSys::TitleType::Application, m_manual_provider->AddEntry(FileSys::TitleType::Application,
FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()), FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()),
program_id, file); 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.name),
Common::Android::ToJString(env, patch.version), static_cast<jint>(patch.type), 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.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); env->SetObjectArrayElement(jpatchArray, i, jpatch);
++i; ++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); 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" } // extern "C"

View File

@ -11,7 +11,7 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="vertical"
android:padding="16dp" android:padding="16dp"
android:layout_gravity="center_vertical"> android:layout_gravity="center_vertical">
@ -23,12 +23,25 @@
android:layout_gravity="center_vertical|start" android:layout_gravity="center_vertical|start"
android:requiresFadingEdge="horizontal" android:requiresFadingEdge="horizontal"
android:textAlignment="viewStart" android:textAlignment="viewStart"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toTopOf="@+id/type_indicator"
app:layout_constraintEnd_toStartOf="@+id/button_layout" app:layout_constraintEnd_toStartOf="@+id/button_layout"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="@string/select_gpu_driver_default" /> 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 <LinearLayout
android:id="@+id/button_layout" android:id="@+id/button_layout"
android:layout_width="wrap_content" 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 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. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</string> </string>
<string name="external_content">External Content</string>
<string name="add_folders">Add Folder</string>
</resources> </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 // SPDX-License-Identifier: GPL-3.0-or-later
#include <jni.h> #include <jni.h>
@ -516,7 +516,7 @@ namespace Common::Android {
s_patch_class = reinterpret_cast<jclass>(env->NewGlobalRef(patch_class)); s_patch_class = reinterpret_cast<jclass>(env->NewGlobalRef(patch_class));
s_patch_constructor = env->GetMethodID( s_patch_constructor = env->GetMethodID(
patch_class, "<init>", 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_enabled_field = env->GetFieldID(patch_class, "enabled", "Z");
s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;"); s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;");
s_patch_version_field = env->GetFieldID(patch_class, "version", "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}; Category::DataStorage};
Setting<std::string> gamecard_path{linkage, std::string(), "gamecard_path", Setting<std::string> gamecard_path{linkage, std::string(), "gamecard_path",
Category::DataStorage}; Category::DataStorage};
std::vector<std::string> external_content_dirs;
// Debugging // Debugging
bool record_frame_times; 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
@ -137,12 +137,127 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const {
return exefs; return exefs;
const auto& disabled = Settings::values.disabled_addons[title_id]; 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 // Game Updates
const auto update_tid = GetUpdateTitleID(title_id); std::unique_ptr<NCA> update = nullptr;
const auto update = content_provider.GetEntry(update_tid, ContentRecordType::Program);
// 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) { if (!update_disabled && update != nullptr && update->GetExeFS() != nullptr) {
LOG_INFO(Loader, " ExeFS: Update ({}) applied successfully", LOG_INFO(Loader, " ExeFS: Update ({}) applied successfully",
@ -447,21 +562,103 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs
// Game Updates // Game Updates
const auto update_tid = GetUpdateTitleID(title_id); 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& 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) { if (!update_disabled && update_raw != nullptr && base_nca != nullptr) {
const auto new_nca = std::make_shared<NCA>(update_raw, base_nca); const auto new_nca = std::make_shared<NCA>(update_raw, base_nca);
if (new_nca->GetStatus() == Loader::ResultStatus::Success && if (new_nca->GetStatus() == Loader::ResultStatus::Success &&
new_nca->GetRomFS() != nullptr) { new_nca->GetRomFS() != nullptr) {
LOG_INFO(Loader, " RomFS: Update ({}) applied successfully", LOG_INFO(Loader, " RomFS: Update ({}) applied successfully",
enabled_version.has_value() ? FormatTitleVersion(*enabled_version) :
FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0))); FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0)));
romfs = new_nca->GetRomFS(); 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) { } else if (!update_disabled && packed_update_raw != nullptr && base_nca != nullptr) {
const auto new_nca = std::make_shared<NCA>(packed_update_raw, base_nca); 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 // Game Updates
const auto update_tid = GetUpdateTitleID(title_id); 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}; PatchManager update{update_tid, fs_controller, content_provider};
const auto metadata = update.GetControlMetadata(); const auto metadata = update.GetControlMetadata();
const auto& nacp = metadata.first; 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(); std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
Patch update_patch = {.enabled = !update_disabled, Patch update_patch = {.enabled = !update_disabled,
.name = "Update", .name = "Update",
.version = "", .version = "",
.type = PatchType::Update, .type = PatchType::Update,
.program_id = title_id, .program_id = title_id,
.title_id = title_id}; .title_id = title_id,
.source = PatchSource::Unknown,
.numeric_version = 0};
if (nacp != nullptr) { if (nacp != nullptr) {
update_patch.version = nacp->GetVersionString(); update_patch.version = nacp->GetVersionString();
@ -513,13 +852,16 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
out.push_back(update_patch); out.push_back(update_patch);
} else { } else {
update_patch.version = FormatTitleVersion(*meta_ver); update_patch.version = FormatTitleVersion(*meta_ver);
update_patch.numeric_version = *meta_ver;
out.push_back(update_patch); out.push_back(update_patch);
} }
} else if (update_raw != nullptr) { } else if (update_raw != nullptr) {
update_patch.version = "PACKED"; update_patch.version = "PACKED";
update_patch.source = PatchSource::Packed;
out.push_back(update_patch); out.push_back(update_patch);
} }
} }
}
// General Mods (LayeredFS and IPS) // General Mods (LayeredFS and IPS)
const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id); const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id);
@ -533,7 +875,8 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
.version = "Cheats", .version = "Cheats",
.type = PatchType::Mod, .type = PatchType::Mod,
.program_id = title_id, .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, .version = types,
.type = PatchType::Mod, .type = PatchType::Mod,
.program_id = title_id, .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, .version = types,
.type = PatchType::Mod, .type = PatchType::Mod,
.program_id = title_id, .program_id = title_id,
.title_id = title_id}); .title_id = title_id,
.source = PatchSource::Unknown});
} }
} }
// DLC // DLC
const auto dlc_entries = const auto dlc_entries =
content_provider.ListEntriesFilter(TitleType::AOC, ContentRecordType::Data); content_provider.ListEntriesFilter(TitleType::AOC, ContentRecordType::Data);
std::vector<ContentProviderEntry> dlc_match; std::vector<ContentProviderEntry> dlc_match;
dlc_match.reserve(dlc_entries.size()); dlc_match.reserve(dlc_entries.size());
std::copy_if(dlc_entries.begin(), dlc_entries.end(), std::back_inserter(dlc_match), std::copy_if(dlc_entries.begin(), dlc_entries.end(), std::back_inserter(dlc_match),
[this](const ContentProviderEntry& entry) { [this](const ContentProviderEntry& entry) {
return GetBaseTitleID(entry.title_id) == title_id && const auto base_tid = GetBaseTitleID(entry.title_id);
content_provider.GetEntry(entry)->GetStatus() == const bool matches_base = base_tid == title_id;
Loader::ResultStatus::Success;
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()) { if (!dlc_match.empty()) {
// Ensure sorted so DLC IDs show in order. // Ensure sorted so DLC IDs show in order.
std::sort(dlc_match.begin(), dlc_match.end()); 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), .version = std::move(list),
.type = PatchType::DLC, .type = PatchType::DLC,
.program_id = title_id, .program_id = title_id,
.title_id = dlc_match.back().title_id}); .title_id = dlc_match.back().title_id,
.source = PatchSource::Unknown});
} }
return out; 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-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -28,6 +31,13 @@ class NACP;
enum class PatchType { Update, DLC, Mod }; enum class PatchType { Update, DLC, Mod };
enum class PatchSource {
Unknown,
NAND,
External,
Packed,
};
struct Patch { struct Patch {
bool enabled; bool enabled;
std::string name; std::string name;
@ -35,6 +45,8 @@ struct Patch {
PatchType type; PatchType type;
u64 program_id; u64 program_id;
u64 title_id; u64 title_id;
PatchSource source;
u32 numeric_version{0};
}; };
// A centralized class to manage patches to games. // 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
@ -13,12 +13,15 @@
#include "common/hex_util.h" #include "common/hex_util.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "common/scope_exit.h" #include "common/scope_exit.h"
#include "common/string_util.h"
#include "core/crypto/key_manager.h" #include "core/crypto/key_manager.h"
#include "core/file_sys/card_image.h" #include "core/file_sys/card_image.h"
#include "core/file_sys/common_funcs.h" #include "core/file_sys/common_funcs.h"
#include "core/file_sys/content_archive.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/nca_metadata.h"
#include "core/file_sys/registered_cache.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/submission_package.h"
#include "core/file_sys/vfs/vfs_concat.h" #include "core/file_sys/vfs/vfs_concat.h"
#include "core/loader/loader.h" #include "core/loader/loader.h"
@ -974,6 +977,22 @@ std::optional<ContentProviderUnionSlot> ContentProviderUnion::GetSlotForEntry(
return iter->first; 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; ManualContentProvider::~ManualContentProvider() = default;
void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType content_type, 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); 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() { void ManualContentProvider::ClearAllEntries() {
entries.clear(); entries.clear();
multi_version_entries.clear();
} }
void ManualContentProvider::Refresh() {} void ManualContentProvider::Refresh() {}
@ -1036,4 +1098,459 @@ std::vector<ContentProviderEntry> ManualContentProvider::ListEntriesFilter(
out.erase(std::unique(out.begin(), out.end()), out.end()); out.erase(std::unique(out.begin(), out.end()), out.end());
return out; 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 } // 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-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -14,7 +17,8 @@
#include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs.h"
namespace FileSys { namespace FileSys {
class CNMT; class ExternalContentProvider;
class CNMT;
class NCA; class NCA;
class NSP; class NSP;
class XCI; class XCI;
@ -48,6 +52,13 @@ struct ContentProviderEntry {
std::string DebugInfo() const; 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) { constexpr u64 GetUpdateTitleID(u64 base_title_id) {
return base_title_id | 0x800; return base_title_id | 0x800;
} }
@ -208,6 +219,7 @@ enum class ContentProviderUnionSlot {
UserNAND, ///< User NAND UserNAND, ///< User NAND
SDMC, ///< SD Card SDMC, ///< SD Card
FrontendManual, ///< Frontend-defined game list or similar 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. // 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<TitleType> title_type, std::optional<ContentRecordType> record_type,
std::optional<u64> title_id) const override; 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::vector<std::pair<ContentProviderUnionSlot, ContentProviderEntry>> ListEntriesFilterOrigin(
std::optional<ContentProviderUnionSlot> origin = {}, std::optional<ContentProviderUnionSlot> origin = {},
std::optional<TitleType> title_type = {}, std::optional<ContentRecordType> record_type = {}, 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, void AddEntry(TitleType title_type, ContentRecordType content_type, u64 title_id,
VirtualFile file); 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 ClearAllEntries();
void Refresh() override; void Refresh() override;
@ -258,8 +275,46 @@ public:
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type, std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
std::optional<u64> title_id) const override; 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: private:
std::map<std::tuple<TitleType, ContentRecordType, u64>, VirtualFile> entries; 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 } // 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-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // 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}] = ncas[next_nca->GetTitleId()][{cnmt.GetType(), rec.type}] =
std::move(next_nca); std::move(next_nca);
} else { } 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); ncas[cnmt.GetTitleID()][{cnmt.GetType(), rec.type}] = std::move(next_nca);
} }
} else { } 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
@ -9,6 +9,7 @@
#include "common/assert.h" #include "common/assert.h"
#include "common/fs/fs.h" #include "common/fs/fs.h"
#include "common/fs/path_util.h" #include "common/fs/path_util.h"
#include "common/logging/log.h"
#include "common/settings.h" #include "common/settings.h"
#include "core/core.h" #include "core/core.h"
#include "core/file_sys/bis_factory.h" #include "core/file_sys/bis_factory.h"
@ -507,6 +508,10 @@ FileSys::RegisteredCache* FileSystemController::GetSDMCContents() const {
return sdmc_factory->GetSDMCContents(); return sdmc_factory->GetSDMCContents();
} }
FileSys::ExternalContentProvider* FileSystemController::GetExternalContentProvider() const {
return external_provider.get();
}
FileSys::PlaceholderCache* FileSystemController::GetSystemNANDPlaceholder() const { FileSys::PlaceholderCache* FileSystemController::GetSystemNANDPlaceholder() const {
LOG_TRACE(Service_FS, "Opening System NAND Placeholder"); LOG_TRACE(Service_FS, "Opening System NAND Placeholder");
@ -684,6 +689,7 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove
if (overwrite) { if (overwrite) {
bis_factory = nullptr; bis_factory = nullptr;
sdmc_factory = nullptr; sdmc_factory = nullptr;
external_provider = nullptr;
} }
using EdenPath = Common::FS::EdenPath; using EdenPath = Common::FS::EdenPath;
@ -716,6 +722,36 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove
system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC, system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC,
sdmc_factory->GetSDMCContents()); 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() { 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-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -17,6 +20,7 @@ class System;
namespace FileSys { namespace FileSys {
class BISFactory; class BISFactory;
class ExternalContentProvider;
class NCA; class NCA;
class RegisteredCache; class RegisteredCache;
class RegisteredCacheUnion; class RegisteredCacheUnion;
@ -117,6 +121,8 @@ public:
FileSys::VirtualDir GetBCATDirectory(u64 title_id) const; 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 // Creates the SaveData, SDMC, and BIS Factories. Should be called once and before any function
// above is called. // above is called.
void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true); void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true);
@ -138,6 +144,8 @@ private:
std::unique_ptr<FileSys::SDMCFactory> sdmc_factory; std::unique_ptr<FileSys::SDMCFactory> sdmc_factory;
std::unique_ptr<FileSys::BISFactory> bis_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::XCI> gamecard;
std::unique_ptr<FileSys::RegisteredCache> gamecard_registered; std::unique_ptr<FileSys::RegisteredCache> gamecard_registered;
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder; 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-FileCopyrightText: 2023 yuzu Emulator Project
@ -231,6 +231,16 @@ void QtConfig::ReadPathValues() {
QString::fromStdString(ReadStringSetting(std::string("recentFiles"))) QString::fromStdString(ReadStringSetting(std::string("recentFiles")))
.split(QStringLiteral(", "), Qt::SkipEmptyParts, Qt::CaseSensitive); .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); ReadCategory(Settings::Category::Paths);
EndGroup(); EndGroup();
@ -446,6 +456,13 @@ void QtConfig::SavePathValues() {
WriteStringSetting(std::string("recentFiles"), WriteStringSetting(std::string("recentFiles"),
UISettings::values.recent_files.join(QStringLiteral(", ")).toStdString()); 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(); 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 // SPDX-License-Identifier: GPL-3.0-or-later
#include "qt_common/util/game.h" #include "qt_common/util/game.h"
@ -373,27 +373,23 @@ void RemoveCacheStorage(u64 program_id)
} }
// Metadata // // Metadata //
void ResetMetadata(bool show_message) void ResetMetadata(bool show_message) {
{
const QString title = tr("Reset Metadata Cache"); const QString title = tr("Reset Metadata Cache");
if (!Common::FS::Exists(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) if (!Common::FS::Exists(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) /
/ "game_list/")) { "game_list/")) {
if (show_message) if (show_message)
QtCommon::Frontend::Warning(rootObject, QtCommon::Frontend::Warning(rootObject, title,
title,
tr("The metadata cache is already empty.")); tr("The metadata cache is already empty."));
} else if (Common::FS::RemoveDirRecursively( } else if (Common::FS::RemoveDirRecursively(
Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "game_list")) { Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "game_list")) {
if (show_message) if (show_message)
QtCommon::Frontend::Information(rootObject, QtCommon::Frontend::Information(rootObject, title,
title,
tr("The operation completed successfully.")); tr("The operation completed successfully."));
UISettings::values.is_game_list_reload_pending.exchange(true); UISettings::values.is_game_list_reload_pending.exchange(true);
} else { } else {
if (show_message) if (show_message)
QtCommon::Frontend::Warning( QtCommon::Frontend::Warning(
title, title,
tr("The metadata cache couldn't be deleted. It might be in use or non-existent.")); 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2016 Citra Emulator Project // 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(ui_tab.get(), &ConfigureUi::LanguageChanged, this, &ConfigureDialog::OnLanguageChanged);
connect(general_tab.get(), &ConfigureGeneral::ExternalContentDirsChanged, this,
&ConfigureDialog::ExternalContentDirsChanged);
connect(ui->selectorList, &QListWidget::itemSelectionChanged, this, connect(ui->selectorList, &QListWidget::itemSelectionChanged, this,
&ConfigureDialog::UpdateVisibleTabs); &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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-FileCopyrightText: 2016 Citra Emulator Project
@ -62,6 +62,7 @@ private slots:
signals: signals:
void LanguageChanged(const QString& locale); void LanguageChanged(const QString& locale);
void ExternalContentDirsChanged();
private: private:
void changeEvent(QEvent* event) override; 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project
@ -38,9 +38,9 @@ ConfigureFilesystem::ConfigureFilesystem(QWidget* parent)
connect(ui->reset_game_list_cache, &QPushButton::pressed, this, connect(ui->reset_game_list_cache, &QPushButton::pressed, this,
&ConfigureFilesystem::ResetMetadata); &ConfigureFilesystem::ResetMetadata);
connect(ui->gamecard_inserted, &QCheckBox::STATE_CHANGED, this, connect(ui->gamecard_inserted, &QCheckBox::stateChanged, this,
&ConfigureFilesystem::UpdateEnabledControls); &ConfigureFilesystem::UpdateEnabledControls);
connect(ui->gamecard_current_game, &QCheckBox::STATE_CHANGED, this, connect(ui->gamecard_current_game, &QCheckBox::stateChanged, this,
&ConfigureFilesystem::UpdateEnabledControls); &ConfigureFilesystem::UpdateEnabledControls);
} }
@ -278,6 +278,7 @@ void ConfigureFilesystem::UpdateEnabledControls() {
!ui->gamecard_current_game->isChecked()); !ui->gamecard_current_game->isChecked());
} }
void ConfigureFilesystem::RetranslateUI() { void ConfigureFilesystem::RetranslateUI() {
ui->retranslateUi(this); 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project // 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-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-FileCopyrightText: 2016 Citra Emulator Project
@ -7,6 +7,9 @@
#include <functional> #include <functional>
#include <utility> #include <utility>
#include <vector> #include <vector>
#include <QDir>
#include <QFileDialog>
#include <QListWidget>
#include <QMessageBox> #include <QMessageBox>
#include "common/settings.h" #include "common/settings.h"
#include "core/core.h" #include "core/core.h"
@ -29,6 +32,15 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
connect(ui->button_reset_defaults, &QPushButton::clicked, this, connect(ui->button_reset_defaults, &QPushButton::clicked, this,
&ConfigureGeneral::ResetDefaults); &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()) { if (!Settings::IsConfiguringGlobal()) {
ui->button_reset_defaults->setVisible(false); ui->button_reset_defaults->setVisible(false);
} }
@ -36,7 +48,9 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
ConfigureGeneral::~ConfigureGeneral() = default; ConfigureGeneral::~ConfigureGeneral() = default;
void ConfigureGeneral::SetConfiguration() {} void ConfigureGeneral::SetConfiguration() {
UpdateExternalContentList();
}
void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) { void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) {
QLayout& general_layout = *ui->general_widget->layout(); QLayout& general_layout = *ui->general_widget->layout();
@ -101,6 +115,55 @@ void ConfigureGeneral::ApplyConfiguration() {
for (const auto& func : apply_funcs) { for (const auto& func : apply_funcs) {
func(powered_on); 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) { 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-FileCopyrightText: 2016 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -39,12 +42,19 @@ public:
void ApplyConfiguration() override; void ApplyConfiguration() override;
void SetConfiguration() override; void SetConfiguration() override;
signals:
void ExternalContentDirsChanged();
private: private:
void Setup(const ConfigurationShared::Builder& builder); void Setup(const ConfigurationShared::Builder& builder);
void changeEvent(QEvent* event) override; void changeEvent(QEvent* event) override;
void RetranslateUI(); void RetranslateUI();
void UpdateExternalContentList();
void AddExternalContentDirectory();
void RemoveSelectedExternalContentDirectory();
std::function<void()> reset_callback; std::function<void()> reset_callback;
std::unique_ptr<Ui::ConfigureGeneral> ui; std::unique_ptr<Ui::ConfigureGeneral> ui;

View File

@ -46,6 +46,66 @@
</layout> </layout>
</widget> </widget>
</item> </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> <item>
<spacer name="verticalSpacer"> <spacer name="verticalSpacer">
<property name="orientation"> <property name="orientation">

View File

@ -8,6 +8,8 @@
#include <memory> #include <memory>
#include <utility> #include <utility>
#include <fmt/format.h>
#include <QHeaderView> #include <QHeaderView>
#include <QMenu> #include <QMenu>
#include <QStandardItemModel> #include <QStandardItemModel>
@ -16,6 +18,7 @@
#include <QTreeView> #include <QTreeView>
#include <qstandardpaths.h> #include <qstandardpaths.h>
#include "common/common_types.h"
#include "common/fs/fs.h" #include "common/fs/fs.h"
#include "common/fs/path_util.h" #include "common/fs/path_util.h"
#include "configuration/addon/mod_select_dialog.h" #include "configuration/addon/mod_select_dialog.h"
@ -68,6 +71,8 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
ui->scrollArea->setEnabled(!system.IsPoweredOn()); ui->scrollArea->setEnabled(!system.IsPoweredOn());
connect(item_model, &QStandardItemModel::itemChanged, this,
&ConfigurePerGameAddons::OnItemChanged);
connect(item_model, &QStandardItemModel::itemChanged, connect(item_model, &QStandardItemModel::itemChanged,
[] { UISettings::values.is_game_list_reload_pending.exchange(true); }); [] { UISettings::values.is_game_list_reload_pending.exchange(true); });
@ -77,14 +82,38 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
ConfigurePerGameAddons::~ConfigurePerGameAddons() = default; 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() { void ConfigurePerGameAddons::ApplyConfiguration() {
std::vector<std::string> disabled_addons; std::vector<std::string> disabled_addons;
for (const auto& item : list_items) { for (const auto& item : list_items) {
const auto disabled = item.front()->checkState() == Qt::Unchecked; 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()); disabled_addons.push_back(item.front()->text().toStdString());
} }
}
}
auto current = Settings::values.disabled_addons[title_id]; auto current = Settings::values.disabled_addons[title_id];
std::sort(disabled_addons.begin(), disabled_addons.end()); std::sort(disabled_addons.begin(), disabled_addons.end());
@ -194,17 +223,51 @@ void ConfigurePerGameAddons::LoadConfiguration() {
const auto& disabled = Settings::values.disabled_addons[title_id]; 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); const auto name = QString::fromStdString(patch.name);
auto* const first_item = new QStandardItem; auto* const first_item = new QStandardItem;
first_item->setText(name); first_item->setText(name);
first_item->setCheckable(true); first_item->setCheckable(true);
const auto patch_disabled = const bool is_external_update = patch.type == FileSys::PatchType::Update &&
std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end(); 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*>{ list_items.push_back(QList<QStandardItem*>{
first_item, new QStandardItem{QString::fromStdString(patch.version)}}); first_item, new QStandardItem{QString::fromStdString(patch.version)}});

View File

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

View File

@ -16,11 +16,13 @@
#include <QToolButton> #include <QToolButton>
#include <QVariantAnimation> #include <QVariantAnimation>
#include <fmt/ranges.h> #include <fmt/ranges.h>
#include <qfilesystemwatcher.h>
#include <qnamespace.h> #include <qnamespace.h>
#include <qscroller.h> #include <qscroller.h>
#include <qscrollerproperties.h> #include <qscrollerproperties.h>
#include "common/common_types.h" #include "common/common_types.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "common/settings.h"
#include "core/core.h" #include "core/core.h"
#include "core/file_sys/patch_manager.h" #include "core/file_sys/patch_manager.h"
#include "core/file_sys/registered_cache.h" #include "core/file_sys/registered_cache.h"
@ -32,6 +34,7 @@
#include "yuzu/game_list_worker.h" #include "yuzu/game_list_worker.h"
#include "yuzu/main_window.h" #include "yuzu/main_window.h"
#include "yuzu/util/controller_navigation.h" #include "yuzu/util/controller_navigation.h"
#include "qt_common/qt_common.h"
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent) GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent)
: QObject(parent), gamelist{gamelist_} {} : QObject(parent), gamelist{gamelist_} {}
@ -325,6 +328,10 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
watcher = new QFileSystemWatcher(this); watcher = new QFileSystemWatcher(this);
connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory); 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; this->main_window = parent;
layout = new QVBoxLayout; layout = new QVBoxLayout;
tree_view = new QTreeView; tree_view = new QTreeView;
@ -919,12 +926,38 @@ const QStringList GameList::supported_file_extensions = {
void GameList::RefreshGameDirectory() 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) { if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) {
LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list.");
QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs);
PopulateAsync(UISettings::values.game_dirs); 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) { void GameList::ToggleFavorite(u64 program_id) {
if (!UISettings::values.favorited_ids.contains(program_id)) { if (!UISettings::values.favorited_ids.contains(program_id)) {
tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(),

View File

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

View File

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