diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt index 2be91ba46a..371fef0025 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -10,6 +10,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding import org.yuzu.yuzu_emu.model.Patch +import org.yuzu.yuzu_emu.model.PatchType import org.yuzu.yuzu_emu.model.AddonViewModel import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder @@ -31,7 +32,12 @@ class AddonAdapter(val addonViewModel: AddonViewModel) : binding.addonSwitch.isChecked = model.enabled binding.addonSwitch.setOnCheckedChangeListener { _, checked -> - model.enabled = checked + if (PatchType.from(model.type) == PatchType.Update && checked) { + addonViewModel.enableOnlyThisUpdate(model) + notifyDataSetChanged() + } else { + model.enabled = checked + } } val deleteAction = { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt index 5cbd15d2ac..d53bebf796 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -7,8 +10,10 @@ import android.net.Uri import android.view.LayoutInflater import android.view.ViewGroup import androidx.fragment.app.FragmentActivity +import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.CardFolderBinding import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment +import org.yuzu.yuzu_emu.model.DirectoryType import org.yuzu.yuzu_emu.model.GameDir import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.utils.ViewUtils.marquee @@ -31,6 +36,12 @@ class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesVie path.text = Uri.parse(model.uriString).path path.marquee() + // Set type indicator, shows below folder name, to see if DLC or Games + typeIndicator.text = when (model.type) { + DirectoryType.GAME -> activity.getString(R.string.games) + DirectoryType.EXTERNAL_CONTENT -> activity.getString(R.string.external_content) + } + buttonEdit.setOnClickListener { GameFolderPropertiesDialogFragment.newInstance(model) .show( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt index 1ea1e036e6..ff26ac0d89 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -6,11 +9,13 @@ package org.yuzu.yuzu_emu.fragments import android.app.Dialog import android.content.DialogInterface import android.os.Bundle +import android.view.View import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding +import org.yuzu.yuzu_emu.model.DirectoryType import org.yuzu.yuzu_emu.model.GameDir import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.utils.NativeConfig @@ -25,14 +30,18 @@ class GameFolderPropertiesDialogFragment : DialogFragment() { val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) val gameDir = requireArguments().parcelable(GAME_DIR)!! - // Restore checkbox state - binding.deepScanSwitch.isChecked = - savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan + // Hide deepScan for external content, do automatically + if (gameDir.type == DirectoryType.EXTERNAL_CONTENT) { + binding.deepScanSwitch.visibility = View.GONE + } else { + // Restore checkbox state for game dirs + binding.deepScanSwitch.isChecked = + savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan - // Ensure that we can get the checkbox state even if the view is destroyed - deepScan = binding.deepScanSwitch.isChecked - binding.deepScanSwitch.setOnClickListener { deepScan = binding.deepScanSwitch.isChecked + binding.deepScanSwitch.setOnClickListener { + deepScan = binding.deepScanSwitch.isChecked + } } return MaterialAlertDialogBuilder(requireContext()) @@ -41,8 +50,10 @@ class GameFolderPropertiesDialogFragment : DialogFragment() { .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) if (folderIndex != -1) { - gamesViewModel.folders.value[folderIndex].deepScan = - binding.deepScanSwitch.isChecked + if (gameDir.type == DirectoryType.GAME) { + gamesViewModel.folders.value[folderIndex].deepScan = + binding.deepScanSwitch.isChecked + } gamesViewModel.updateGameDirs() } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt index 87b1533408..9c43d2c6e1 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -15,11 +15,14 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.transition.MaterialSharedAxis import kotlinx.coroutines.launch import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.adapters.FolderAdapter import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding +import org.yuzu.yuzu_emu.model.DirectoryType +import org.yuzu.yuzu_emu.model.GameDir import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.ui.main.MainActivity @@ -73,7 +76,25 @@ class GameFoldersFragment : Fragment() { val mainActivity = requireActivity() as MainActivity binding.buttonAdd.setOnClickListener { - mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + // Show a model to choose between Game and External Content + val options = arrayOf( + getString(R.string.games), + getString(R.string.external_content) + ) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.add_folders) + .setItems(options) { _, which -> + when (which) { + 0 -> { // Game Folder + mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + } + 1 -> { // External Content Folder + mainActivity.getExternalContentDirectory.launch(null) + } + } + } + .show() } setInsets() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index d21b4e5d91..918478bf85 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + package org.yuzu.yuzu_emu.fragments import android.Manifest diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt index b9c8e49ca4..c682a13cfc 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -48,16 +51,68 @@ class AddonViewModel : ViewModel() { ?: emptyArray() ).toMutableList() patchList.sortBy { it.name } + + // Ensure only one update is enabled + ensureSingleUpdateEnabled(patchList) + + removeDuplicates(patchList) + _patchList.value = patchList isRefreshing.set(false) } } } + private fun ensureSingleUpdateEnabled(patchList: MutableList) { + 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) { + val seen = mutableSetOf() + val iterator = patchList.iterator() + while (iterator.hasNext()) { + val patch = iterator.next() + val key = "${patch.name}|${patch.version}|${patch.type}" + if (seen.contains(key)) { + iterator.remove() + } else { + seen.add(key) + } + } + } + fun setAddonToDelete(patch: Patch?) { _addonToDelete.value = patch } + fun enableOnlyThisUpdate(selectedPatch: Patch) { + val currentList = _patchList.value + for (patch in currentList) { + if (PatchType.from(patch.type) == PatchType.Update) { + patch.enabled = (patch === selectedPatch) + } + } + } + fun onDeleteAddon(patch: Patch) { when (PatchType.from(patch.type)) { PatchType.Update -> NativeLibrary.removeUpdate(patch.programId) @@ -72,13 +127,27 @@ class AddonViewModel : ViewModel() { return } + // Check if there are multiple update versions + val updates = _patchList.value.filter { PatchType.from(it.type) == PatchType.Update } + val hasMultipleUpdates = updates.size > 1 + NativeConfig.setDisabledAddons( game!!.programId, _patchList.value.mapNotNull { if (it.enabled) { null } else { - it.name + if (PatchType.from(it.type) == PatchType.Update) { + if (it.name.contains("(NAND)") || it.name.contains("(SDMC)")) { + it.name + } else if (hasMultipleUpdates) { + "Update@${it.numericVersion}" + } else { + it.name + } + } else { + it.name + } } }.toTypedArray() ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt index 274bc1c7bc..b7113bf937 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -9,5 +12,14 @@ import kotlinx.parcelize.Parcelize @Parcelize data class GameDir( val uriString: String, - var deepScan: Boolean -) : Parcelable + var deepScan: Boolean, + val type: DirectoryType = DirectoryType.GAME +) : Parcelable { + // Needed for JNI backward compatability + constructor(uriString: String, deepScan: Boolean) : this(uriString, deepScan, DirectoryType.GAME) +} + +enum class DirectoryType { + GAME, + EXTERNAL_CONTENT +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt index ae5f8f89de..39ff038034 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.model @@ -56,7 +56,7 @@ class GamesViewModel : ViewModel() { // Ensure keys are loaded so that ROM metadata can be decrypted. NativeLibrary.reloadKeys() - getGameDirs() + getGameDirsAndExternalContent() reloadGames(directoriesChanged = false, firstStartup = true) } @@ -144,11 +144,19 @@ class GamesViewModel : ViewModel() { fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) = viewModelScope.launch { withContext(Dispatchers.IO) { - NativeConfig.addGameDir(gameDir) - val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - .getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true) - - getGameDirs(!isFirstTimeSetup) + when (gameDir.type) { + DirectoryType.GAME -> { + NativeConfig.addGameDir(gameDir) + val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + .getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true) + getGameDirsAndExternalContent(!isFirstTimeSetup) + } + DirectoryType.EXTERNAL_CONTENT -> { + addExternalContentDir(gameDir.uriString) + NativeConfig.saveGlobalConfig() + getGameDirsAndExternalContent() + } + } } if (savedFromGameFragment) { @@ -168,8 +176,15 @@ class GamesViewModel : ViewModel() { val removedDirIndex = gameDirs.indexOf(gameDir) if (removedDirIndex != -1) { gameDirs.removeAt(removedDirIndex) - NativeConfig.setGameDirs(gameDirs.toTypedArray()) - getGameDirs() + when (gameDir.type) { + DirectoryType.GAME -> { + NativeConfig.setGameDirs(gameDirs.filter { it.type == DirectoryType.GAME }.toTypedArray()) + } + DirectoryType.EXTERNAL_CONTENT -> { + removeExternalContentDir(gameDir.uriString) + } + } + getGameDirsAndExternalContent() } } } @@ -177,15 +192,16 @@ class GamesViewModel : ViewModel() { fun updateGameDirs() = viewModelScope.launch { withContext(Dispatchers.IO) { - NativeConfig.setGameDirs(_folders.value.toTypedArray()) - getGameDirs() + val gameDirs = _folders.value.filter { it.type == DirectoryType.GAME } + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + getGameDirsAndExternalContent() } } fun onOpenGameFoldersFragment() = viewModelScope.launch { withContext(Dispatchers.IO) { - getGameDirs() + getGameDirsAndExternalContent() } } @@ -193,16 +209,36 @@ class GamesViewModel : ViewModel() { NativeConfig.saveGlobalConfig() viewModelScope.launch { withContext(Dispatchers.IO) { - getGameDirs(true) + getGameDirsAndExternalContent(true) } } } - private fun getGameDirs(reloadList: Boolean = false) { - val gameDirs = NativeConfig.getGameDirs() - _folders.value = gameDirs.toMutableList() + private fun getGameDirsAndExternalContent(reloadList: Boolean = false) { + val gameDirs = NativeConfig.getGameDirs().toMutableList() + val externalContentDirs = NativeConfig.getExternalContentDirs().map { + GameDir(it, false, DirectoryType.EXTERNAL_CONTENT) + } + gameDirs.addAll(externalContentDirs) + _folders.value = gameDirs if (reloadList) { reloadGames(true) } } + + private fun addExternalContentDir(path: String) { + val currentDirs = NativeConfig.getExternalContentDirs().toMutableList() + if (!currentDirs.contains(path)) { + currentDirs.add(path) + NativeConfig.setExternalContentDirs(currentDirs.toTypedArray()) + NativeConfig.saveGlobalConfig() + } + } + + private fun removeExternalContentDir(path: String) { + val currentDirs = NativeConfig.getExternalContentDirs().toMutableList() + currentDirs.remove(path) + NativeConfig.setExternalContentDirs(currentDirs.toTypedArray()) + NativeConfig.saveGlobalConfig() + } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt index 25cb9e3654..8a367116c1 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -12,5 +15,6 @@ data class Patch( val version: String, val type: Int, val programId: String, - val titleId: String + val titleId: String, + val numericVersion: Long = 0 ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 8186a6b18f..8edec4ff46 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -63,6 +63,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import androidx.documentfile.provider.DocumentFile class MainActivity : AppCompatActivity(), ThemeProvider { private lateinit var binding: ActivityMainBinding @@ -389,6 +390,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } } + val getExternalContentDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processExternalContentDir(result) + } + } + fun processGamesDir(result: Uri, calledFromGameFragment: Boolean = false) { contentResolver.takePersistableUriPermission( result, @@ -410,6 +418,27 @@ class MainActivity : AppCompatActivity(), ThemeProvider { .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) } + fun processExternalContentDir(result: Uri) { + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val uriString = result.toString() + val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString } + if (folder != null) { + Toast.makeText( + applicationContext, + R.string.folder_already_added, + Toast.LENGTH_SHORT + ).show() + return + } + + val externalContentDir = org.yuzu.yuzu_emu.model.GameDir(uriString, false, org.yuzu.yuzu_emu.model.DirectoryType.EXTERNAL_CONTENT) + gamesViewModel.addFolder(externalContentDir, savedFromGameFragment = false) + } + val getProdKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> if (result != null) { processKey(result, "keys") diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index e27bc94696..fff5fdfb9b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -1,9 +1,9 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later -// SPDX-FileCopyrightText: 2025 Eden Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - package org.yuzu.yuzu_emu.utils import android.content.SharedPreferences @@ -49,6 +49,17 @@ object GameHelper { // Remove previous filesystem provider information so we can get up to date version info NativeLibrary.clearFilesystemProvider() + // Scan External Content directories and register all NSP/XCI files + val externalContentDirs = NativeConfig.getExternalContentDirs() + for (externalDir in externalContentDirs) { + if (externalDir.isNotEmpty()) { + val externalDirUri = externalDir.toUri() + if (FileUtil.isTreeUriValid(externalDirUri)) { + scanExternalContentRecursive(FileUtil.listFiles(externalDirUri), 3) + } + } + } + val badDirs = mutableListOf() gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> val gameDirUri = gameDir.uriString.toUri() @@ -88,6 +99,33 @@ object GameHelper { return games.toList() } + // File extensions considered as external content, buuut should + // be done better imo. + private val externalContentExtensions = setOf("nsp", "xci") + + private fun scanExternalContentRecursive( + files: Array, + depth: Int + ) { + if (depth <= 0) { + return + } + + files.forEach { + if (it.isDirectory) { + scanExternalContentRecursive( + FileUtil.listFiles(it.uri), + depth - 1 + ) + } else { + val extension = FileUtil.getExtension(it.uri).lowercase() + if (externalContentExtensions.contains(extension)) { + NativeLibrary.addFileToFilesystemProvider(it.uri.toString()) + } + } + } + } + private fun addGamesRecursive( games: MutableList, files: Array, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt index d53672af26..d1b5b1373d 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -204,4 +204,12 @@ object NativeConfig { external fun getSdmcDir(): String @Synchronized external fun setSdmcDir(path: String) + + /** + * External Content Provider + */ + @Synchronized + external fun getExternalContentDirs(): Array + @Synchronized + external fun setExternalContentDirs(dirs: Array) } diff --git a/src/android/app/src/main/jni/android_config.cpp b/src/android/app/src/main/jni/android_config.cpp index 7345a1893f..0171e2a7b3 100644 --- a/src/android/app/src/main/jni/android_config.cpp +++ b/src/android/app/src/main/jni/android_config.cpp @@ -1,8 +1,9 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include #include +#include #include #include "android_config.h" #include "android_settings.h" @@ -69,6 +70,18 @@ void AndroidConfig::ReadPathValues() { } EndArray(); + // Read external content directories + Settings::values.external_content_dirs.clear(); + const int external_dirs_size = BeginArray(std::string("external_content_dirs")); + for (int i = 0; i < external_dirs_size; ++i) { + SetArrayIndex(i); + std::string dir_path = ReadStringSetting(std::string("path")); + if (!dir_path.empty()) { + Settings::values.external_content_dirs.push_back(dir_path); + } + } + EndArray(); + const auto nand_dir_setting = ReadStringSetting(std::string("nand_directory")); if (!nand_dir_setting.empty()) { Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting); @@ -241,6 +254,14 @@ void AndroidConfig::SavePathValues() { } EndArray(); + // Save external content directories + BeginArray(std::string("external_content_dirs")); + for (size_t i = 0; i < Settings::values.external_content_dirs.size(); ++i) { + SetArrayIndex(i); + WriteStringSetting(std::string("path"), Settings::values.external_content_dirs[i]); + } + EndArray(); + // Save custom NAND directory const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir); WriteStringSetting(std::string("nand_directory"), nand_path, diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index ac1a189e75..195a100983 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -55,8 +55,11 @@ #include "core/crypto/key_manager.h" #include "core/file_sys/card_image.h" #include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.h" #include "core/file_sys/fs_filesystem.h" #include "core/file_sys/romfs.h" +#include "core/file_sys/nca_metadata.h" +#include "core/file_sys/romfs.h" #include "core/file_sys/submission_package.h" #include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs_real.h" @@ -212,6 +215,109 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath) return; } + const auto extension = Common::ToLower(filepath.substr(filepath.find_last_of('.') + 1)); + + if (extension == "nsp") { + auto nsp = std::make_shared(file); + if (nsp->GetStatus() == Loader::ResultStatus::Success) { + std::map nsp_versions; + std::map 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(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(title_type), static_cast(content_type)); + } + } + } + return; + } + } + + // Handle XCI files + if (extension == "xci") { + FileSys::XCI xci{file}; + if (xci.GetStatus() == Loader::ResultStatus::Success) { + const auto nsp = xci.GetSecurePartitionNSP(); + if (nsp) { + for (const auto& title : nsp->GetNCAs()) { + for (const auto& entry : title.second) { + m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first, + entry.second->GetBaseFile()); + } + } + } + return; + } + } + auto loader = Loader::GetLoader(m_system, file); if (!loader) { return; @@ -228,17 +334,6 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath) m_manual_provider->AddEntry(FileSys::TitleType::Application, FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()), program_id, file); - } else if (res2 == Loader::ResultStatus::Success && - (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { - const auto nsp = file_type == Loader::FileType::NSP - ? std::make_shared(file) - : FileSys::XCI{file}.GetSecurePartitionNSP(); - for (const auto& title : nsp->GetNCAs()) { - for (const auto& entry : title.second) { - m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first, - entry.second->GetBaseFile()); - } - } } } @@ -1333,7 +1428,8 @@ jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPatchesForFile(JNIEnv* env Common::Android::ToJString(env, patch.name), Common::Android::ToJString(env, patch.version), static_cast(patch.type), Common::Android::ToJString(env, std::to_string(patch.program_id)), - Common::Android::ToJString(env, std::to_string(patch.title_id))); + Common::Android::ToJString(env, std::to_string(patch.title_id)), + static_cast(patch.numeric_version)); env->SetObjectArrayElement(jpatchArray, i, jpatch); ++i; } diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp index 81c0afc7ef..4536175e9d 100644 --- a/src/android/app/src/main/jni/native_config.cpp +++ b/src/android/app/src/main/jni/native_config.cpp @@ -583,4 +583,26 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSdmcDir(JNIEnv* env, jobject Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, path); } +jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getExternalContentDirs(JNIEnv* env, + jobject obj) { + const auto& dirs = Settings::values.external_content_dirs; + jobjectArray jdirsArray = + env->NewObjectArray(dirs.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < dirs.size(); ++i) { + env->SetObjectArrayElement(jdirsArray, i, Common::Android::ToJString(env, dirs[i])); + } + return jdirsArray; +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setExternalContentDirs(JNIEnv* env, jobject obj, + jobjectArray jdirs) { + Settings::values.external_content_dirs.clear(); + const int size = env->GetArrayLength(jdirs); + for (int i = 0; i < size; ++i) { + auto jdir = static_cast(env->GetObjectArrayElement(jdirs, i)); + Settings::values.external_content_dirs.push_back(Common::Android::GetJString(env, jdir)); + } +} + } // extern "C" diff --git a/src/android/app/src/main/res/layout/card_folder.xml b/src/android/app/src/main/res/layout/card_folder.xml index e3a5f1a867..517063e7ac 100644 --- a/src/android/app/src/main/res/layout/card_folder.xml +++ b/src/android/app/src/main/res/layout/card_folder.xml @@ -11,7 +11,7 @@ @@ -23,12 +23,25 @@ android:layout_gravity="center_vertical|start" android:requiresFadingEdge="horizontal" android:textAlignment="viewStart" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/type_indicator" app:layout_constraintEnd_toStartOf="@+id/button_layout" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="@string/select_gpu_driver_default" /> + + + + External Content + Add Folder + diff --git a/src/common/android/id_cache.cpp b/src/common/android/id_cache.cpp index eb43f4e213..c7f5332a68 100644 --- a/src/common/android/id_cache.cpp +++ b/src/common/android/id_cache.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include @@ -516,7 +516,7 @@ namespace Common::Android { s_patch_class = reinterpret_cast(env->NewGlobalRef(patch_class)); s_patch_constructor = env->GetMethodID( patch_class, "", - "(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V"); + "(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;J)V"); s_patch_enabled_field = env->GetFieldID(patch_class, "enabled", "Z"); s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;"); s_patch_version_field = env->GetFieldID(patch_class, "version", "Ljava/lang/String;"); diff --git a/src/common/settings.h b/src/common/settings.h index f29d041c17..41f766a5e7 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -715,6 +715,7 @@ struct Values { Category::DataStorage}; Setting gamecard_path{linkage, std::string(), "gamecard_path", Category::DataStorage}; + std::vector external_content_dirs; // Debugging bool record_frame_times; diff --git a/src/core/file_sys/patch_manager.cpp b/src/core/file_sys/patch_manager.cpp index 657172fb4d..86c2076380 100644 --- a/src/core/file_sys/patch_manager.cpp +++ b/src/core/file_sys/patch_manager.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project @@ -137,12 +137,127 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const { return exefs; const auto& disabled = Settings::values.disabled_addons[title_id]; - const auto update_disabled = - std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + + bool update_disabled = true; + std::optional enabled_version; + bool checked_external = false; + bool checked_manual = false; + + const auto* content_union = dynamic_cast(&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( + content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual)); + if (manual_provider) { + const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid); + + if (!manual_update_versions.empty()) { + checked_manual = true; + for (const auto& update_entry : manual_update_versions) { + std::string disabled_key = fmt::format("Update@{}", update_entry.version); + if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) { + update_disabled = false; + enabled_version = update_entry.version; + break; + } + } + } + } + } + } + + // check for original NAND style + // Check NAND if: no external updates exist, OR all external updates are disabled + if (!checked_external && !checked_manual) { + // Only enable NAND update if it exists AND is not disabled + // We need to check if an update actually exists in the content provider + const bool has_nand_update = content_provider.HasEntry(update_tid, ContentRecordType::Program); + + if (has_nand_update) { + const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend(); + const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend(); + const bool generic_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + + if (!nand_disabled && !sdmc_disabled && !generic_disabled) { + update_disabled = false; + } + } + } else if (update_disabled && content_union) { + const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend(); + const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend(); + + if (!nand_disabled || !sdmc_disabled) { + const auto nand_sdmc_entries = content_union->ListEntriesFilterOrigin( + std::nullopt, TitleType::Update, ContentRecordType::Program, update_tid); + + for (const auto& [slot, entry] : nand_sdmc_entries) { + if (slot == ContentProviderUnionSlot::UserNAND || + slot == ContentProviderUnionSlot::SysNAND) { + if (!nand_disabled) { + update_disabled = false; + break; + } + } else if (slot == ContentProviderUnionSlot::SDMC) { + if (!sdmc_disabled) { + update_disabled = false; + break; + } + } + } + } + } // Game Updates - const auto update_tid = GetUpdateTitleID(title_id); - const auto update = content_provider.GetEntry(update_tid, ContentRecordType::Program); + std::unique_ptr update = nullptr; + + // If we have a specific enabled version from external provider, use it + if (enabled_version.has_value() && content_union) { + const auto* external_provider = content_union->GetExternalProvider(); + if (external_provider) { + auto file = external_provider->GetEntryForVersion(update_tid, ContentRecordType::Program, *enabled_version); + if (file != nullptr) { + update = std::make_unique(file); + } + } + + // Also try ManualContentProvider + if (update == nullptr) { + const auto* manual_provider = dynamic_cast( + 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(file); + } + } + } + } + + // Fallback to regular content provider if no external update was loaded + if (update == nullptr && !update_disabled) { + update = content_provider.GetEntry(update_tid, ContentRecordType::Program); + } if (!update_disabled && update != nullptr && update->GetExeFS() != nullptr) { LOG_INFO(Loader, " ExeFS: Update ({}) applied successfully", @@ -447,21 +562,103 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs // Game Updates const auto update_tid = GetUpdateTitleID(title_id); - const auto update_raw = content_provider.GetEntryRaw(update_tid, type); - const auto& disabled = Settings::values.disabled_addons[title_id]; - const auto update_disabled = - std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + + bool update_disabled = true; + std::optional enabled_version; + VirtualFile update_raw = nullptr; + bool checked_external = false; + bool checked_manual = false; + + const auto* content_union = dynamic_cast(&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( + content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual)); + if (manual_provider) { + const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid); + + if (!manual_update_versions.empty()) { + checked_manual = true; + for (const auto& update_entry : manual_update_versions) { + std::string disabled_key = fmt::format("Update@{}", update_entry.version); + if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) { + update_disabled = false; + enabled_version = update_entry.version; + update_raw = manual_provider->GetEntryForVersion(update_tid, type, update_entry.version); + break; + } + } + } + } + } + } + + if (!checked_external && !checked_manual) { + const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend(); + const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend(); + const bool generic_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + + if (!nand_disabled && !sdmc_disabled && !generic_disabled) { + update_disabled = false; + } + if (!update_disabled) { + update_raw = content_provider.GetEntryRaw(update_tid, type); + } + } else if (update_disabled && content_union) { + const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend(); + const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend(); + + if (!nand_disabled || !sdmc_disabled) { + const auto nand_sdmc_entries = content_union->ListEntriesFilterOrigin( + std::nullopt, TitleType::Update, type, update_tid); + + for (const auto& [slot, entry] : nand_sdmc_entries) { + if (slot == ContentProviderUnionSlot::UserNAND || + slot == ContentProviderUnionSlot::SysNAND) { + if (!nand_disabled) { + update_disabled = false; + update_raw = content_provider.GetEntryRaw(update_tid, type); + break; + } + } else if (slot == ContentProviderUnionSlot::SDMC) { + if (!sdmc_disabled) { + update_disabled = false; + update_raw = content_provider.GetEntryRaw(update_tid, type); + break; + } + } + } + } + } if (!update_disabled && update_raw != nullptr && base_nca != nullptr) { const auto new_nca = std::make_shared(update_raw, base_nca); if (new_nca->GetStatus() == Loader::ResultStatus::Success && new_nca->GetRomFS() != nullptr) { LOG_INFO(Loader, " RomFS: Update ({}) applied successfully", + enabled_version.has_value() ? FormatTitleVersion(*enabled_version) : FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0))); romfs = new_nca->GetRomFS(); - const auto version = - FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0)); } } else if (!update_disabled && packed_update_raw != nullptr && base_nca != nullptr) { const auto new_nca = std::make_shared(packed_update_raw, base_nca); @@ -490,34 +687,179 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { // Game Updates const auto update_tid = GetUpdateTitleID(title_id); - PatchManager update{update_tid, fs_controller, content_provider}; - const auto metadata = update.GetControlMetadata(); - const auto& nacp = metadata.first; - const auto update_disabled = - std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); - Patch update_patch = {.enabled = !update_disabled, - .name = "Update", - .version = "", - .type = PatchType::Update, - .program_id = title_id, - .title_id = title_id}; + std::vector external_update_patches; + + const auto* content_union = dynamic_cast(&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( + content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual)); + if (manual_provider && external_update_patches.empty()) { + const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid); + + for (const auto& update_entry : manual_update_versions) { + std::string version_str = update_entry.version_string; + if (version_str.empty()) { + version_str = FormatTitleVersion(update_entry.version); + } + + std::string disabled_key = fmt::format("Update@{}", update_entry.version); + const auto update_disabled = + std::find(disabled.cbegin(), disabled.cend(), disabled_key) != disabled.cend(); + + + Patch update_patch = {.enabled = !update_disabled, + .name = "Update", + .version = version_str, + .type = PatchType::Update, + .program_id = title_id, + .title_id = update_tid, + .source = PatchSource::External, + .numeric_version = update_entry.version}; + + external_update_patches.push_back(update_patch); + } + } + + if (external_update_patches.size() > 1) { + bool found_enabled = false; + for (auto& patch : external_update_patches) { + if (patch.enabled) { + if (found_enabled) { + patch.enabled = false; + } else { + found_enabled = true; + } + } + } + } + + for (auto& patch : external_update_patches) { + out.push_back(std::move(patch)); + } + + const auto all_updates = content_union->ListEntriesFilterOrigin( + std::nullopt, std::nullopt, ContentRecordType::Program, update_tid); + + for (const auto& [slot, entry] : all_updates) { + if (slot == ContentProviderUnionSlot::External) { + continue; + } + + PatchSource source_type = PatchSource::Unknown; + std::string source_suffix; + + switch (slot) { + case ContentProviderUnionSlot::UserNAND: + case ContentProviderUnionSlot::SysNAND: + source_type = PatchSource::NAND; + source_suffix = " (NAND)"; + break; + case ContentProviderUnionSlot::SDMC: + source_type = PatchSource::NAND; + source_suffix = " (SDMC)"; + break; + default: + break; + } + + std::string version_str; + u32 numeric_ver = 0; + PatchManager update{update_tid, fs_controller, content_provider}; + const auto metadata = update.GetControlMetadata(); + const auto& nacp = metadata.first; + + if (nacp != nullptr) { + version_str = nacp->GetVersionString(); + } - if (nacp != nullptr) { - update_patch.version = nacp->GetVersionString(); - out.push_back(update_patch); - } else { - if (content_provider.HasEntry(update_tid, ContentRecordType::Program)) { const auto meta_ver = content_provider.GetEntryVersion(update_tid); - if (meta_ver.value_or(0) == 0) { - out.push_back(update_patch); - } else { - update_patch.version = FormatTitleVersion(*meta_ver); + if (meta_ver.has_value()) { + numeric_ver = *meta_ver; + if (version_str.empty() && numeric_ver != 0) { + version_str = FormatTitleVersion(numeric_ver); + } + } + + std::string patch_name = "Update" + source_suffix; + + bool update_disabled = + std::find(disabled.cbegin(), disabled.cend(), patch_name) != disabled.cend(); + + Patch update_patch = {.enabled = !update_disabled, + .name = patch_name, + .version = version_str, + .type = PatchType::Update, + .program_id = title_id, + .title_id = update_tid, + .source = source_type, + .numeric_version = numeric_ver}; + + out.push_back(update_patch); + } + } else { + PatchManager update{update_tid, fs_controller, content_provider}; + const auto metadata = update.GetControlMetadata(); + const auto& nacp = metadata.first; + + bool update_disabled = + std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + Patch update_patch = {.enabled = !update_disabled, + .name = "Update", + .version = "", + .type = PatchType::Update, + .program_id = title_id, + .title_id = title_id, + .source = PatchSource::Unknown, + .numeric_version = 0}; + + if (nacp != nullptr) { + update_patch.version = nacp->GetVersionString(); + out.push_back(update_patch); + } else { + if (content_provider.HasEntry(update_tid, ContentRecordType::Program)) { + const auto meta_ver = content_provider.GetEntryVersion(update_tid); + if (meta_ver.value_or(0) == 0) { + out.push_back(update_patch); + } else { + update_patch.version = FormatTitleVersion(*meta_ver); + update_patch.numeric_version = *meta_ver; + out.push_back(update_patch); + } + } else if (update_raw != nullptr) { + update_patch.version = "PACKED"; + update_patch.source = PatchSource::Packed; out.push_back(update_patch); } - } else if (update_raw != nullptr) { - update_patch.version = "PACKED"; - out.push_back(update_patch); } } @@ -533,7 +875,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = "Cheats", .type = PatchType::Mod, .program_id = title_id, - .title_id = title_id + .title_id = title_id, + .source = PatchSource::Unknown }); } @@ -554,7 +897,7 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { } else if (std::find(EXEFS_FILE_NAMES.begin(), EXEFS_FILE_NAMES.end(), file->GetName()) != EXEFS_FILE_NAMES.end()) { layeredfs = true; - } + } } if (ips) @@ -579,7 +922,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = types, .type = PatchType::Mod, .program_id = title_id, - .title_id = title_id}); + .title_id = title_id, + .source = PatchSource::Unknown}); } } @@ -593,7 +937,7 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(sdmc_mod_dir, "romfs")) || IsDirValidAndNonEmpty(FindSubdirectoryCaseless(sdmc_mod_dir, "romfslite"))) { AppendCommaIfNotEmpty(types, "LayeredFS"); - } + } if (!types.empty()) { const auto mod_disabled = @@ -603,21 +947,44 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = types, .type = PatchType::Mod, .program_id = title_id, - .title_id = title_id}); + .title_id = title_id, + .source = PatchSource::Unknown}); } } // DLC const auto dlc_entries = content_provider.ListEntriesFilter(TitleType::AOC, ContentRecordType::Data); + std::vector dlc_match; dlc_match.reserve(dlc_entries.size()); std::copy_if(dlc_entries.begin(), dlc_entries.end(), std::back_inserter(dlc_match), [this](const ContentProviderEntry& entry) { - return GetBaseTitleID(entry.title_id) == title_id && - content_provider.GetEntry(entry)->GetStatus() == - Loader::ResultStatus::Success; + const auto base_tid = GetBaseTitleID(entry.title_id); + const bool matches_base = base_tid == title_id; + + if (!matches_base) { + LOG_DEBUG(Loader, "DLC {:016X} base {:016X} doesn't match title {:016X}", + entry.title_id, base_tid, title_id); + return false; + } + + auto nca = content_provider.GetEntry(entry); + if (!nca) { + LOG_DEBUG(Loader, "Failed to get NCA for DLC {:016X}", entry.title_id); + return false; + } + + const auto status = nca->GetStatus(); + if (status != Loader::ResultStatus::Success) { + LOG_DEBUG(Loader, "DLC {:016X} NCA has status {}", + entry.title_id, static_cast(status)); + return false; + } + + return true; }); + if (!dlc_match.empty()) { // Ensure sorted so DLC IDs show in order. std::sort(dlc_match.begin(), dlc_match.end()); @@ -635,7 +1002,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .version = std::move(list), .type = PatchType::DLC, .program_id = title_id, - .title_id = dlc_match.back().title_id}); + .title_id = dlc_match.back().title_id, + .source = PatchSource::Unknown}); } return out; diff --git a/src/core/file_sys/patch_manager.h b/src/core/file_sys/patch_manager.h index 552c0fbe23..ecd2086984 100644 --- a/src/core/file_sys/patch_manager.h +++ b/src/core/file_sys/patch_manager.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -28,6 +31,13 @@ class NACP; enum class PatchType { Update, DLC, Mod }; +enum class PatchSource { + Unknown, + NAND, + External, + Packed, +}; + struct Patch { bool enabled; std::string name; @@ -35,6 +45,8 @@ struct Patch { PatchType type; u64 program_id; u64 title_id; + PatchSource source; + u32 numeric_version{0}; }; // A centralized class to manage patches to games. diff --git a/src/core/file_sys/registered_cache.cpp b/src/core/file_sys/registered_cache.cpp index f750a2e871..42ec878436 100644 --- a/src/core/file_sys/registered_cache.cpp +++ b/src/core/file_sys/registered_cache.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project @@ -13,12 +13,15 @@ #include "common/hex_util.h" #include "common/logging/log.h" #include "common/scope_exit.h" +#include "common/string_util.h" #include "core/crypto/key_manager.h" #include "core/file_sys/card_image.h" #include "core/file_sys/common_funcs.h" #include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.h" #include "core/file_sys/nca_metadata.h" #include "core/file_sys/registered_cache.h" +#include "core/file_sys/romfs.h" #include "core/file_sys/submission_package.h" #include "core/file_sys/vfs/vfs_concat.h" #include "core/loader/loader.h" @@ -974,6 +977,22 @@ std::optional ContentProviderUnion::GetSlotForEntry( return iter->first; } +const ExternalContentProvider* ContentProviderUnion::GetExternalProvider() const { + auto it = providers.find(ContentProviderUnionSlot::External); + if (it != providers.end() && it->second != nullptr) { + return dynamic_cast(it->second); + } + return nullptr; +} + +const ContentProvider* ContentProviderUnion::GetSlotProvider(ContentProviderUnionSlot slot) const { + auto it = providers.find(slot); + if (it != providers.end()) { + return it->second; + } + return nullptr; +} + ManualContentProvider::~ManualContentProvider() = default; void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType content_type, @@ -981,8 +1000,51 @@ void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType con entries.insert_or_assign({title_type, content_type, title_id}, file); } +void ManualContentProvider::AddEntryWithVersion(TitleType title_type, ContentRecordType content_type, + u64 title_id, u32 version, + const std::string& version_string, VirtualFile file) { + if (title_type == TitleType::Update) { + auto it = std::find_if(multi_version_entries.begin(), multi_version_entries.end(), + [title_id, version](const ExternalUpdateEntry& entry) { + return entry.title_id == title_id && entry.version == version; + }); + + if (it != multi_version_entries.end()) { + // Update existing entry + it->files[content_type] = file; + if (!version_string.empty()) { + it->version_string = version_string; + } + } else { + // Add new entry + ExternalUpdateEntry new_entry; + new_entry.title_id = title_id; + new_entry.version = version; + new_entry.version_string = version_string; + new_entry.files[content_type] = file; + multi_version_entries.push_back(new_entry); + } + + auto existing = entries.find({title_type, content_type, title_id}); + if (existing == entries.end()) { + entries.insert_or_assign({title_type, content_type, title_id}, file); + } else { + // Check if this version is higher + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id && entry.version > version) { + return; // Don't replace with lower version + } + } + entries.insert_or_assign({title_type, content_type, title_id}, file); + } + } else { + entries.insert_or_assign({title_type, content_type, title_id}, file); + } +} + void ManualContentProvider::ClearAllEntries() { entries.clear(); + multi_version_entries.clear(); } void ManualContentProvider::Refresh() {} @@ -1036,4 +1098,459 @@ std::vector ManualContentProvider::ListEntriesFilter( out.erase(std::unique(out.begin(), out.end()), out.end()); return out; } + +std::vector ManualContentProvider::ListUpdateVersions(u64 title_id) const { + std::vector out; + + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id) { + out.push_back(entry); + } + } + + std::sort(out.begin(), out.end(), [](const ExternalUpdateEntry& a, const ExternalUpdateEntry& b) { + return a.version > b.version; + }); + + return out; +} + +VirtualFile 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 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 nsp_versions; + std::map nsp_version_strings; // title_id -> NACP version string + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (content_type == ContentRecordType::Meta) { + const auto subdirs = nca->GetSubdirectories(); + if (!subdirs.empty()) { + const auto section0 = subdirs[0]; + const auto files = section0->GetFiles(); + for (const auto& inner_file : files) { + if (inner_file->GetExtension() == "cnmt") { + const CNMT cnmt(inner_file); + const auto cnmt_title_id = cnmt.GetTitleID(); + const auto version = cnmt.GetTitleVersion(); + nsp_versions[cnmt_title_id] = version; + versions[cnmt_title_id] = version; + break; + } + } + } + } + + if (content_type == ContentRecordType::Control && title_type == TitleType::Update) { + auto romfs = nca->GetRomFS(); + if (romfs) { + auto extracted = ExtractRomFS(romfs); + if (extracted) { + auto nacp_file = extracted->GetFile("control.nacp"); + if (!nacp_file) { + nacp_file = extracted->GetFile("Control.nacp"); + } + if (nacp_file) { + NACP nacp(nacp_file); + auto ver_str = nacp.GetVersionString(); + if (!ver_str.empty()) { + nsp_version_strings[title_id] = ver_str; + } + } + } + } + } + } + } + + std::map, std::map> version_files; + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (title_type != TitleType::AOC && title_type != TitleType::Update) { + continue; + } + + auto nca_file = nsp.GetNCAFile(title_id, content_type, title_type); + if (nca_file != nullptr) { + entries[{title_id, content_type, title_type}] = nca_file; + + if (title_type == TitleType::Update) { + u32 version = 0; + auto ver_it = nsp_versions.find(title_id); + if (ver_it != nsp_versions.end()) { + version = ver_it->second; + } + + version_files[{title_id, version}][content_type] = nca_file; + } + + LOG_DEBUG(Service_FS, "Added entry - Title ID: {:016X}, Type: {}, Content: {}", + title_id, static_cast(title_type), static_cast(content_type)); + } + } + } + + for (const auto& [key, files_map] : version_files) { + const auto& [title_id, version] = key; + + std::string ver_str; + auto str_it = nsp_version_strings.find(title_id); + if (str_it != nsp_version_strings.end()) { + ver_str = str_it->second; + } + + bool version_exists = false; + for (auto& existing : multi_version_entries) { + if (existing.title_id == title_id && existing.version == version) { + for (const auto& [content_type, _file] : files_map) { + existing.files[content_type] = _file; + } + if (existing.version_string.empty() && !ver_str.empty()) { + existing.version_string = ver_str; + } + version_exists = true; + break; + } + } + + if (!version_exists && !files_map.empty()) { + ExternalUpdateEntry update_entry{ + .title_id = title_id, + .version = version, + .version_string = ver_str, + .files = files_map + }; + multi_version_entries.push_back(update_entry); + LOG_DEBUG(Service_FS, "Added multi-version update - Title ID: {:016X}, Version: {}, VersionStr: {}, Content types: {}", + title_id, version, ver_str, files_map.size()); + } + } +} + +void ExternalContentProvider::ProcessXCI(const VirtualFile& file) { + auto xci = XCI(file); + if (xci.GetStatus() != Loader::ResultStatus::Success) { + return; + } + + auto nsp = xci.GetSecurePartitionNSP(); + if (nsp == nullptr) { + return; + } + + const auto ncas = nsp->GetNCAs(); + + std::map xci_versions; + std::map xci_version_strings; + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (content_type == ContentRecordType::Meta) { + const auto subdirs = nca->GetSubdirectories(); + if (!subdirs.empty()) { + const auto section0 = subdirs[0]; + const auto files = section0->GetFiles(); + for (const auto& inner_file : files) { + if (inner_file->GetExtension() == "cnmt") { + const CNMT cnmt(inner_file); + const auto cnmt_title_id = cnmt.GetTitleID(); + const auto version = cnmt.GetTitleVersion(); + xci_versions[cnmt_title_id] = version; + versions[cnmt_title_id] = version; + break; + } + } + } + } + + if (content_type == ContentRecordType::Control && title_type == TitleType::Update) { + auto romfs = nca->GetRomFS(); + if (romfs) { + auto extracted = ExtractRomFS(romfs); + if (extracted) { + auto nacp_file = extracted->GetFile("control.nacp"); + if (!nacp_file) { + nacp_file = extracted->GetFile("Control.nacp"); + } + if (nacp_file) { + NACP nacp(nacp_file); + auto ver_str = nacp.GetVersionString(); + if (!ver_str.empty()) { + xci_version_strings[title_id] = ver_str; + } + } + } + } + } + } + } + + std::map, std::map> version_files; + + for (const auto& [title_id, nca_map] : ncas) { + for (const auto& [type_pair, nca] : nca_map) { + const auto& [title_type, content_type] = type_pair; + + if (title_type != TitleType::AOC && title_type != TitleType::Update) { + continue; + } + + auto nca_file = nsp->GetNCAFile(title_id, content_type, title_type); + if (nca_file != nullptr) { + entries[{title_id, content_type, title_type}] = nca_file; + + if (title_type == TitleType::Update) { + u32 version = 0; + auto ver_it = xci_versions.find(title_id); + if (ver_it != xci_versions.end()) { + version = ver_it->second; + } + + version_files[{title_id, version}][content_type] = nca_file; + } + } + } + } + + for (const auto& [key, files_map] : version_files) { + const auto& [title_id, version] = key; + + std::string ver_str; + auto str_it = xci_version_strings.find(title_id); + if (str_it != xci_version_strings.end()) { + ver_str = str_it->second; + } + + bool version_exists = false; + for (auto& existing : multi_version_entries) { + if (existing.title_id == title_id && existing.version == version) { + for (const auto& [content_type, _file] : files_map) { + existing.files[content_type] = _file; + } + if (existing.version_string.empty() && !ver_str.empty()) { + existing.version_string = ver_str; + } + version_exists = true; + break; + } + } + + if (!version_exists && !files_map.empty()) { + ExternalUpdateEntry update_entry{ + .title_id = title_id, + .version = version, + .version_string = ver_str, + .files = files_map + }; + multi_version_entries.push_back(update_entry); + LOG_DEBUG(Service_FS, "Added multi-version update from XCI - Title ID: {:016X}, Version: {}, VersionStr: {}, Content types: {}", + title_id, version, ver_str, files_map.size()); + } + } +} + +bool ExternalContentProvider::HasEntry(u64 title_id, ContentRecordType type) const { + return GetEntryRaw(title_id, type) != nullptr; +} + +std::optional ExternalContentProvider::GetEntryVersion(u64 title_id) const { + const auto it = versions.find(title_id); + if (it != versions.end()) { + return it->second; + } + return std::nullopt; +} + +VirtualFile ExternalContentProvider::GetEntryUnparsed(u64 title_id, ContentRecordType type) const { + return GetEntryRaw(title_id, type); +} + +VirtualFile ExternalContentProvider::GetEntryRaw(u64 title_id, ContentRecordType type) const { + // Try to find in AOC (DLC) entries + { + const auto it = entries.find({title_id, type, TitleType::AOC}); + if (it != entries.end()) { + return it->second; + } + } + + // Try to find in Update entries + { + const auto it = entries.find({title_id, type, TitleType::Update}); + if (it != entries.end()) { + return it->second; + } + } + + return nullptr; +} + +std::unique_ptr ExternalContentProvider::GetEntry(u64 title_id, + ContentRecordType type) const { + const auto file = GetEntryRaw(title_id, type); + if (file == nullptr) { + return nullptr; + } + return std::make_unique(file); +} + +std::vector ExternalContentProvider::ListEntriesFilter( + std::optional title_type, std::optional record_type, + std::optional title_id) const { + std::vector out; + + for (const auto& [key, file] : entries) { + const auto& [e_title_id, e_content_type, e_title_type] = key; + + if ((title_type == std::nullopt || e_title_type == *title_type) && + (record_type == std::nullopt || e_content_type == *record_type) && + (title_id == std::nullopt || e_title_id == *title_id)) { + out.emplace_back(ContentProviderEntry{e_title_id, e_content_type}); + } + } + + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +std::vector ExternalContentProvider::ListUpdateVersions(u64 title_id) const { + std::vector out; + + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id) { + out.push_back(entry); + } + } + + std::sort(out.begin(), out.end(), [](const ExternalUpdateEntry& a, const ExternalUpdateEntry& b) { + return a.version > b.version; + }); + + return out; +} + +VirtualFile ExternalContentProvider::GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const { + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id && entry.version == version) { + auto it = entry.files.find(type); + if (it != entry.files.end()) { + return it->second; + } + } + } + return nullptr; +} + +bool ExternalContentProvider::HasMultipleVersions(u64 title_id, ContentRecordType type) const { + size_t count = 0; + for (const auto& entry : multi_version_entries) { + if (entry.title_id == title_id && entry.files.count(type) > 0) { + count++; + if (count > 1) { + return true; + } + } + } + return false; +} + } // namespace FileSys diff --git a/src/core/file_sys/registered_cache.h b/src/core/file_sys/registered_cache.h index a7fc556737..04e231f453 100644 --- a/src/core/file_sys/registered_cache.h +++ b/src/core/file_sys/registered_cache.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -14,7 +17,8 @@ #include "core/file_sys/vfs/vfs.h" namespace FileSys { -class CNMT; + class ExternalContentProvider; + class CNMT; class NCA; class NSP; class XCI; @@ -48,6 +52,13 @@ struct ContentProviderEntry { std::string DebugInfo() const; }; +struct ExternalUpdateEntry { + u64 title_id; + u32 version; + std::string version_string; + std::map files; +}; + constexpr u64 GetUpdateTitleID(u64 base_title_id) { return base_title_id | 0x800; } @@ -208,6 +219,7 @@ enum class ContentProviderUnionSlot { UserNAND, ///< User NAND SDMC, ///< SD Card FrontendManual, ///< Frontend-defined game list or similar + External, ///< External content from NSP/XCI files in configured directories }; // Combines multiple ContentProvider(s) (i.e. SysNAND, UserNAND, SDMC) into one interface. @@ -228,6 +240,9 @@ public: std::optional title_type, std::optional record_type, std::optional title_id) const override; + const ExternalContentProvider* GetExternalProvider() const; + const ContentProvider* GetSlotProvider(ContentProviderUnionSlot slot) const; + std::vector> ListEntriesFilterOrigin( std::optional origin = {}, std::optional title_type = {}, std::optional record_type = {}, @@ -246,6 +261,8 @@ public: void AddEntry(TitleType title_type, ContentRecordType content_type, u64 title_id, VirtualFile file); + void AddEntryWithVersion(TitleType title_type, ContentRecordType content_type, u64 title_id, + u32 version, const std::string& version_string, VirtualFile file); void ClearAllEntries(); void Refresh() override; @@ -258,8 +275,46 @@ public: std::optional title_type, std::optional record_type, std::optional title_id) const override; + std::vector ListUpdateVersions(u64 title_id) const; + VirtualFile GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const; + bool HasMultipleVersions(u64 title_id, ContentRecordType type) const; + private: std::map, VirtualFile> entries; + std::vector multi_version_entries; +}; + +class ExternalContentProvider : public ContentProvider { +public: + explicit ExternalContentProvider(std::vector load_directories = {}); + ~ExternalContentProvider() override; + + void AddDirectory(VirtualDir directory); + void ClearDirectories(); + + void Refresh() override; + bool HasEntry(u64 title_id, ContentRecordType type) const override; + std::optional GetEntryVersion(u64 title_id) const override; + VirtualFile GetEntryUnparsed(u64 title_id, ContentRecordType type) const override; + VirtualFile GetEntryRaw(u64 title_id, ContentRecordType type) const override; + std::unique_ptr GetEntry(u64 title_id, ContentRecordType type) const override; + std::vector ListEntriesFilter( + std::optional title_type, std::optional record_type, + std::optional title_id) const override; + + std::vector ListUpdateVersions(u64 title_id) const; + VirtualFile GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const; + bool HasMultipleVersions(u64 title_id, ContentRecordType type) const; + +private: + void ScanDirectory(const VirtualDir& dir); + void ProcessNSP(const VirtualFile& file); + void ProcessXCI(const VirtualFile& file); + + std::vector load_dirs; + std::map, VirtualFile> entries; + std::map versions; + std::vector multi_version_entries; }; } // namespace FileSys diff --git a/src/core/file_sys/submission_package.cpp b/src/core/file_sys/submission_package.cpp index 68e8ec22fc..56286405e2 100644 --- a/src/core/file_sys/submission_package.cpp +++ b/src/core/file_sys/submission_package.cpp @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -275,6 +278,14 @@ void NSP::ReadNCAs(const std::vector& files) { ncas[next_nca->GetTitleId()][{cnmt.GetType(), rec.type}] = std::move(next_nca); } else { + // fix for Bayonetta Origins in Bayonetta 3 and external content + // where multiple update NCAs exist for the same title and type. + auto& target_map = ncas[cnmt.GetTitleID()]; + auto existing = target_map.find({cnmt.GetType(), rec.type}); + + if (existing != target_map.end() && rec.type == ContentRecordType::Program) { + continue; + } ncas[cnmt.GetTitleID()][{cnmt.GetType(), rec.type}] = std::move(next_nca); } } else { diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 95a32c1250..2031052409 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project @@ -9,6 +9,7 @@ #include "common/assert.h" #include "common/fs/fs.h" #include "common/fs/path_util.h" +#include "common/logging/log.h" #include "common/settings.h" #include "core/core.h" #include "core/file_sys/bis_factory.h" @@ -507,6 +508,10 @@ FileSys::RegisteredCache* FileSystemController::GetSDMCContents() const { return sdmc_factory->GetSDMCContents(); } +FileSys::ExternalContentProvider* FileSystemController::GetExternalContentProvider() const { + return external_provider.get(); +} + FileSys::PlaceholderCache* FileSystemController::GetSystemNANDPlaceholder() const { LOG_TRACE(Service_FS, "Opening System NAND Placeholder"); @@ -684,6 +689,7 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove if (overwrite) { bis_factory = nullptr; sdmc_factory = nullptr; + external_provider = nullptr; } using EdenPath = Common::FS::EdenPath; @@ -716,6 +722,36 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC, sdmc_factory->GetSDMCContents()); } + + if (external_provider == nullptr) { + std::vector external_dirs; + + LOG_DEBUG(Service_FS, "Initializing ExternalContentProvider with {} configured directories", + Settings::values.external_content_dirs.size()); + + for (const auto& dir_path : Settings::values.external_content_dirs) { + if (!dir_path.empty()) { + LOG_DEBUG(Service_FS, "Attempting to open directory: {}", dir_path); + auto dir = vfs.OpenDirectory(dir_path, FileSys::OpenMode::Read); + if (dir != nullptr) { + external_dirs.push_back(std::move(dir)); + LOG_DEBUG(Service_FS, "Successfully opened directory: {}", dir_path); + } else { + LOG_ERROR(Service_FS, "Failed to open directory: {}", dir_path); + } + } + } + + LOG_DEBUG(Service_FS, "Creating ExternalContentProvider with {} opened directories", + external_dirs.size()); + + external_provider = std::make_unique( + std::move(external_dirs)); + system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::External, + external_provider.get()); + + LOG_DEBUG(Service_FS, "ExternalContentProvider registered to content provider union"); + } } void FileSystemController::Reset() { diff --git a/src/core/hle/service/filesystem/filesystem.h b/src/core/hle/service/filesystem/filesystem.h index 718500385b..ef45aec627 100644 --- a/src/core/hle/service/filesystem/filesystem.h +++ b/src/core/hle/service/filesystem/filesystem.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -17,6 +20,7 @@ class System; namespace FileSys { class BISFactory; +class ExternalContentProvider; class NCA; class RegisteredCache; class RegisteredCacheUnion; @@ -117,6 +121,8 @@ public: FileSys::VirtualDir GetBCATDirectory(u64 title_id) const; + FileSys::ExternalContentProvider* GetExternalContentProvider() const; + // Creates the SaveData, SDMC, and BIS Factories. Should be called once and before any function // above is called. void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true); @@ -138,6 +144,8 @@ private: std::unique_ptr sdmc_factory; std::unique_ptr bis_factory; + std::unique_ptr external_provider; + std::unique_ptr gamecard; std::unique_ptr gamecard_registered; std::unique_ptr gamecard_placeholder; diff --git a/src/qt_common/config/qt_config.cpp b/src/qt_common/config/qt_config.cpp index 65bf488c5c..c5a8f62745 100644 --- a/src/qt_common/config/qt_config.cpp +++ b/src/qt_common/config/qt_config.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -231,6 +231,16 @@ void QtConfig::ReadPathValues() { QString::fromStdString(ReadStringSetting(std::string("recentFiles"))) .split(QStringLiteral(", "), Qt::SkipEmptyParts, Qt::CaseSensitive); + const int external_dirs_size = BeginArray(std::string("external_content_dirs")); + for (int i = 0; i < external_dirs_size; ++i) { + SetArrayIndex(i); + std::string dir_path = ReadStringSetting(std::string("path")); + if (!dir_path.empty()) { + Settings::values.external_content_dirs.push_back(dir_path); + } + } + EndArray(); + ReadCategory(Settings::Category::Paths); EndGroup(); @@ -446,6 +456,13 @@ void QtConfig::SavePathValues() { WriteStringSetting(std::string("recentFiles"), UISettings::values.recent_files.join(QStringLiteral(", ")).toStdString()); + BeginArray(std::string("external_content_dirs")); + for (int i = 0; i < static_cast(Settings::values.external_content_dirs.size()); ++i) { + SetArrayIndex(i); + WriteStringSetting(std::string("path"), Settings::values.external_content_dirs[i]); + } + EndArray(); + EndGroup(); } diff --git a/src/qt_common/util/game.cpp b/src/qt_common/util/game.cpp index e34a388993..91f43579f3 100644 --- a/src/qt_common/util/game.cpp +++ b/src/qt_common/util/game.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include "qt_common/util/game.h" @@ -373,27 +373,23 @@ void RemoveCacheStorage(u64 program_id) } // Metadata // -void ResetMetadata(bool show_message) -{ +void ResetMetadata(bool show_message) { const QString title = tr("Reset Metadata Cache"); - if (!Common::FS::Exists(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) - / "game_list/")) { + if (!Common::FS::Exists(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / + "game_list/")) { if (show_message) - QtCommon::Frontend::Warning(rootObject, - title, + QtCommon::Frontend::Warning(rootObject, title, tr("The metadata cache is already empty.")); } else if (Common::FS::RemoveDirRecursively( Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "game_list")) { if (show_message) - QtCommon::Frontend::Information(rootObject, - title, + QtCommon::Frontend::Information(rootObject, title, tr("The operation completed successfully.")); UISettings::values.is_game_list_reload_pending.exchange(true); } else { if (show_message) QtCommon::Frontend::Warning( - title, tr("The metadata cache couldn't be deleted. It might be in use or non-existent.")); } diff --git a/src/yuzu/configuration/configure_dialog.cpp b/src/yuzu/configuration/configure_dialog.cpp index c247c53493..1107c77e8c 100644 --- a/src/yuzu/configuration/configure_dialog.cpp +++ b/src/yuzu/configuration/configure_dialog.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2016 Citra Emulator Project @@ -99,6 +99,8 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_, } }); connect(ui_tab.get(), &ConfigureUi::LanguageChanged, this, &ConfigureDialog::OnLanguageChanged); + connect(general_tab.get(), &ConfigureGeneral::ExternalContentDirsChanged, this, + &ConfigureDialog::ExternalContentDirsChanged); connect(ui->selectorList, &QListWidget::itemSelectionChanged, this, &ConfigureDialog::UpdateVisibleTabs); diff --git a/src/yuzu/configuration/configure_dialog.h b/src/yuzu/configuration/configure_dialog.h index 4f9cf79645..9d79e6f0ac 100644 --- a/src/yuzu/configuration/configure_dialog.h +++ b/src/yuzu/configuration/configure_dialog.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2016 Citra Emulator Project @@ -62,6 +62,7 @@ private slots: signals: void LanguageChanged(const QString& locale); + void ExternalContentDirsChanged(); private: void changeEvent(QEvent* event) override; diff --git a/src/yuzu/configuration/configure_filesystem.cpp b/src/yuzu/configuration/configure_filesystem.cpp index 545032eee3..f0310a30bd 100644 --- a/src/yuzu/configuration/configure_filesystem.cpp +++ b/src/yuzu/configuration/configure_filesystem.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project @@ -38,9 +38,9 @@ ConfigureFilesystem::ConfigureFilesystem(QWidget* parent) connect(ui->reset_game_list_cache, &QPushButton::pressed, this, &ConfigureFilesystem::ResetMetadata); - connect(ui->gamecard_inserted, &QCheckBox::STATE_CHANGED, this, + connect(ui->gamecard_inserted, &QCheckBox::stateChanged, this, &ConfigureFilesystem::UpdateEnabledControls); - connect(ui->gamecard_current_game, &QCheckBox::STATE_CHANGED, this, + connect(ui->gamecard_current_game, &QCheckBox::stateChanged, this, &ConfigureFilesystem::UpdateEnabledControls); } @@ -278,6 +278,7 @@ void ConfigureFilesystem::UpdateEnabledControls() { !ui->gamecard_current_game->isChecked()); } + void ConfigureFilesystem::RetranslateUI() { ui->retranslateUi(this); } diff --git a/src/yuzu/configuration/configure_filesystem.h b/src/yuzu/configuration/configure_filesystem.h index d8c26a783a..995e636544 100644 --- a/src/yuzu/configuration/configure_filesystem.h +++ b/src/yuzu/configuration/configure_filesystem.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project diff --git a/src/yuzu/configuration/configure_general.cpp b/src/yuzu/configuration/configure_general.cpp index b2fe566a17..f628abeab3 100644 --- a/src/yuzu/configuration/configure_general.cpp +++ b/src/yuzu/configuration/configure_general.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2016 Citra Emulator Project @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include #include "common/settings.h" #include "core/core.h" @@ -29,6 +32,15 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_, connect(ui->button_reset_defaults, &QPushButton::clicked, this, &ConfigureGeneral::ResetDefaults); + connect(ui->add_external_dir_button, &QPushButton::pressed, this, + &ConfigureGeneral::AddExternalContentDirectory); + connect(ui->remove_external_dir_button, &QPushButton::pressed, this, + &ConfigureGeneral::RemoveSelectedExternalContentDirectory); + connect(ui->external_content_list, &QListWidget::itemSelectionChanged, this, [this] { + ui->remove_external_dir_button->setEnabled( + !ui->external_content_list->selectedItems().isEmpty()); + }); + if (!Settings::IsConfiguringGlobal()) { ui->button_reset_defaults->setVisible(false); } @@ -36,7 +48,9 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_, ConfigureGeneral::~ConfigureGeneral() = default; -void ConfigureGeneral::SetConfiguration() {} +void ConfigureGeneral::SetConfiguration() { + UpdateExternalContentList(); +} void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) { QLayout& general_layout = *ui->general_widget->layout(); @@ -101,6 +115,55 @@ void ConfigureGeneral::ApplyConfiguration() { for (const auto& func : apply_funcs) { func(powered_on); } + + std::vector 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) { diff --git a/src/yuzu/configuration/configure_general.h b/src/yuzu/configuration/configure_general.h index ada6526a6a..983fd50f10 100644 --- a/src/yuzu/configuration/configure_general.h +++ b/src/yuzu/configuration/configure_general.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -39,12 +42,19 @@ public: void ApplyConfiguration() override; void SetConfiguration() override; +signals: + void ExternalContentDirsChanged(); + private: void Setup(const ConfigurationShared::Builder& builder); void changeEvent(QEvent* event) override; void RetranslateUI(); + void UpdateExternalContentList(); + void AddExternalContentDirectory(); + void RemoveSelectedExternalContentDirectory(); + std::function reset_callback; std::unique_ptr ui; diff --git a/src/yuzu/configuration/configure_general.ui b/src/yuzu/configuration/configure_general.ui index a10e7d3a50..78b6081d92 100644 --- a/src/yuzu/configuration/configure_general.ui +++ b/src/yuzu/configuration/configure_general.ui @@ -46,6 +46,66 @@ + + + + External Content + + + + + + Add directories to scan for DLCs and Updates without installing to NAND + + + true + + + + + + + QAbstractItemView::SingleSelection + + + + + + + + + Add Directory + + + + + + + Remove Selected + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + diff --git a/src/yuzu/configuration/configure_per_game_addons.cpp b/src/yuzu/configuration/configure_per_game_addons.cpp index 3032e4c3d9..ecafa6826a 100644 --- a/src/yuzu/configuration/configure_per_game_addons.cpp +++ b/src/yuzu/configuration/configure_per_game_addons.cpp @@ -8,6 +8,8 @@ #include #include +#include + #include #include #include @@ -16,6 +18,7 @@ #include #include +#include "common/common_types.h" #include "common/fs/fs.h" #include "common/fs/path_util.h" #include "configuration/addon/mod_select_dialog.h" @@ -68,6 +71,8 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p ui->scrollArea->setEnabled(!system.IsPoweredOn()); + connect(item_model, &QStandardItemModel::itemChanged, this, + &ConfigurePerGameAddons::OnItemChanged); connect(item_model, &QStandardItemModel::itemChanged, [] { UISettings::values.is_game_list_reload_pending.exchange(true); }); @@ -77,13 +82,37 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p ConfigurePerGameAddons::~ConfigurePerGameAddons() = default; +void ConfigurePerGameAddons::OnItemChanged(QStandardItem* item) { + if (update_items.size() > 1 && item->checkState() == Qt::Checked) { + auto it = std::find(update_items.begin(), update_items.end(), item); + if (it != update_items.end()) { + for (auto* update_item : update_items) { + if (update_item != item && update_item->checkState() == Qt::Checked) { + disconnect(item_model, &QStandardItemModel::itemChanged, this, + &ConfigurePerGameAddons::OnItemChanged); + update_item->setCheckState(Qt::Unchecked); + connect(item_model, &QStandardItemModel::itemChanged, this, + &ConfigurePerGameAddons::OnItemChanged); + } + } + } + } +} + void ConfigurePerGameAddons::ApplyConfiguration() { std::vector disabled_addons; for (const auto& item : list_items) { const auto disabled = item.front()->checkState() == Qt::Unchecked; - if (disabled) - disabled_addons.push_back(item.front()->text().toStdString()); + if (disabled) { + QVariant userData = item.front()->data(Qt::UserRole); + if (userData.isValid() && userData.canConvert() && item.front()->text() == QStringLiteral("Update")) { + quint32 numeric_version = userData.toUInt(); + disabled_addons.push_back(fmt::format("Update@{}", numeric_version)); + } else { + disabled_addons.push_back(item.front()->text().toStdString()); + } + } } auto current = Settings::values.disabled_addons[title_id]; @@ -194,17 +223,51 @@ void ConfigurePerGameAddons::LoadConfiguration() { const auto& disabled = Settings::values.disabled_addons[title_id]; - for (const auto& patch : pm.GetPatches(update_raw)) { + update_items.clear(); + list_items.clear(); + item_model->removeRows(0, item_model->rowCount()); + + std::vector patches = pm.GetPatches(update_raw); + + bool has_enabled_update = false; + + for (const auto& patch : patches) { const auto name = QString::fromStdString(patch.name); auto* const first_item = new QStandardItem; first_item->setText(name); first_item->setCheckable(true); - const auto patch_disabled = - std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end(); + const bool is_external_update = patch.type == FileSys::PatchType::Update && + patch.source == FileSys::PatchSource::External && + patch.numeric_version != 0; - first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked); + if (is_external_update) { + first_item->setData(static_cast(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{ first_item, new QStandardItem{QString::fromStdString(patch.version)}}); diff --git a/src/yuzu/configuration/configure_per_game_addons.h b/src/yuzu/configuration/configure_per_game_addons.h index 8b698b5bec..3c7c0b0ff0 100644 --- a/src/yuzu/configuration/configure_per_game_addons.h +++ b/src/yuzu/configuration/configure_per_game_addons.h @@ -54,6 +54,7 @@ private: void RetranslateUI(); void LoadConfiguration(); + void OnItemChanged(QStandardItem* item); std::unique_ptr ui; FileSys::VirtualFile file; @@ -64,6 +65,7 @@ private: QStandardItemModel* item_model; std::vector> list_items; + std::vector update_items; Core::System& system; }; diff --git a/src/yuzu/game_list.cpp b/src/yuzu/game_list.cpp index 9848c564d0..d206ab096b 100644 --- a/src/yuzu/game_list.cpp +++ b/src/yuzu/game_list.cpp @@ -16,11 +16,13 @@ #include #include #include +#include #include #include #include #include "common/common_types.h" #include "common/logging/log.h" +#include "common/settings.h" #include "core/core.h" #include "core/file_sys/patch_manager.h" #include "core/file_sys/registered_cache.h" @@ -32,6 +34,7 @@ #include "yuzu/game_list_worker.h" #include "yuzu/main_window.h" #include "yuzu/util/controller_navigation.h" +#include "qt_common/qt_common.h" GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent) : QObject(parent), gamelist{gamelist_} {} @@ -325,6 +328,10 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid watcher = new QFileSystemWatcher(this); connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory); + external_watcher = new QFileSystemWatcher(this); + ResetExternalWatcher(); + connect(external_watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshExternalContent); + this->main_window = parent; layout = new QVBoxLayout; tree_view = new QTreeView; @@ -919,12 +926,38 @@ const QStringList GameList::supported_file_extensions = { void GameList::RefreshGameDirectory() { + // Reset the externals watcher whenever the game list is reloaded, + // primarily ensures that new titles and external dirs are caught. + ResetExternalWatcher(); + if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) { LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); + QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs); PopulateAsync(UISettings::values.game_dirs); } } +void GameList::RefreshExternalContent() { + // TODO: Explore the possibility of only resetting the metadata cache for that specific game. + if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) { + LOG_INFO(Frontend, "External content directory changed. Clearing metadata cache."); + QtCommon::Game::ResetMetadata(false); + QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs); + PopulateAsync(UISettings::values.game_dirs); + } +} + +void GameList::ResetExternalWatcher() { + auto watch_dirs = external_watcher->directories(); + if (!watch_dirs.isEmpty()) { + external_watcher->removePaths(watch_dirs); + } + + for (const std::string &dir : Settings::values.external_content_dirs) { + external_watcher->addPath(QString::fromStdString(dir)); + } +} + void GameList::ToggleFavorite(u64 program_id) { if (!UISettings::values.favorited_ids.contains(program_id)) { tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), diff --git a/src/yuzu/game_list.h b/src/yuzu/game_list.h index 293a46a4f2..9b00e270cd 100644 --- a/src/yuzu/game_list.h +++ b/src/yuzu/game_list.h @@ -94,6 +94,8 @@ public: public slots: void RefreshGameDirectory(); + void RefreshExternalContent(); + void ResetExternalWatcher(); signals: void BootGame(const QString& game_path, StartGameType type); @@ -160,6 +162,7 @@ private: QStandardItemModel* item_model = nullptr; std::unique_ptr current_worker; QFileSystemWatcher* watcher = nullptr; + QFileSystemWatcher* external_watcher = nullptr; ControllerNavigation* controller_navigation = nullptr; CompatibilityList compatibility_list; diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 8adcdb4cbe..e44696b6a3 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -3388,6 +3388,8 @@ void MainWindow::OnConfigure() { !multiplayer_state->IsHostingPublicRoom()); connect(&configure_dialog, &ConfigureDialog::LanguageChanged, this, &MainWindow::OnLanguageChanged); + connect(&configure_dialog, &ConfigureDialog::ExternalContentDirsChanged, this, + &MainWindow::OnGameListRefresh); const auto result = configure_dialog.exec(); if (result != QDialog::Accepted && !UISettings::values.configuration_applied && @@ -3907,8 +3909,7 @@ void MainWindow::OnToggleStatusBar() { statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked()); } -void MainWindow::OnGameListRefresh() -{ +void MainWindow::OnGameListRefresh() { // Resets metadata cache and reloads QtCommon::Game::ResetMetadata(false); game_list->RefreshGameDirectory();