[fs/core] Load external content without NAND install (#2862)
Adds the capability to add DLC and Updates without installing them to NAND. This was tested on Windows only and needs Android integration. Co-authored-by: crueter <crueter@eden-emu.dev> Co-authored-by: wildcard <wildcard@eden-emu.dev> Co-authored-by: nekle <nekle@protonmail.com> Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/2862 Reviewed-by: DraVee <dravee@eden-emu.dev> Reviewed-by: crueter <crueter@eden-emu.dev> Co-authored-by: Maufeat <sahyno1996@gmail.com> Co-committed-by: Maufeat <sahyno1996@gmail.com>
This commit is contained in:
parent
e07e269bd7
commit
69aff83ef4
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
|
|
@ -10,6 +10,7 @@ import android.view.LayoutInflater
|
|||
import android.view.ViewGroup
|
||||
import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding
|
||||
import org.yuzu.yuzu_emu.model.Patch
|
||||
import org.yuzu.yuzu_emu.model.PatchType
|
||||
import org.yuzu.yuzu_emu.model.AddonViewModel
|
||||
import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
|
||||
|
||||
|
|
@ -31,8 +32,13 @@ class AddonAdapter(val addonViewModel: AddonViewModel) :
|
|||
binding.addonSwitch.isChecked = model.enabled
|
||||
|
||||
binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
|
||||
if (PatchType.from(model.type) == PatchType.Update && checked) {
|
||||
addonViewModel.enableOnlyThisUpdate(model)
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
model.enabled = checked
|
||||
}
|
||||
}
|
||||
|
||||
val deleteAction = {
|
||||
addonViewModel.setAddonToDelete(model)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
|
@ -6,11 +9,13 @@ package org.yuzu.yuzu_emu.fragments
|
|||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.yuzu.yuzu_emu.R
|
||||
import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding
|
||||
import org.yuzu.yuzu_emu.model.DirectoryType
|
||||
import org.yuzu.yuzu_emu.model.GameDir
|
||||
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||
import org.yuzu.yuzu_emu.utils.NativeConfig
|
||||
|
|
@ -25,15 +30,19 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
|
|||
val binding = DialogFolderPropertiesBinding.inflate(layoutInflater)
|
||||
val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!!
|
||||
|
||||
// Restore checkbox state
|
||||
// Hide deepScan for external content, do automatically
|
||||
if (gameDir.type == DirectoryType.EXTERNAL_CONTENT) {
|
||||
binding.deepScanSwitch.visibility = View.GONE
|
||||
} else {
|
||||
// Restore checkbox state for game dirs
|
||||
binding.deepScanSwitch.isChecked =
|
||||
savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan
|
||||
|
||||
// Ensure that we can get the checkbox state even if the view is destroyed
|
||||
deepScan = binding.deepScanSwitch.isChecked
|
||||
binding.deepScanSwitch.setOnClickListener {
|
||||
deepScan = binding.deepScanSwitch.isChecked
|
||||
}
|
||||
}
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(binding.root)
|
||||
|
|
@ -41,8 +50,10 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
|
|||
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
|
||||
val folderIndex = gamesViewModel.folders.value.indexOf(gameDir)
|
||||
if (folderIndex != -1) {
|
||||
if (gameDir.type == DirectoryType.GAME) {
|
||||
gamesViewModel.folders.value[folderIndex].deepScan =
|
||||
binding.deepScanSwitch.isChecked
|
||||
}
|
||||
gamesViewModel.updateGameDirs()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org.yuzu.yuzu_emu.fragments
|
||||
|
|
@ -15,11 +15,14 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import kotlinx.coroutines.launch
|
||||
import org.yuzu.yuzu_emu.R
|
||||
import org.yuzu.yuzu_emu.adapters.FolderAdapter
|
||||
import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding
|
||||
import org.yuzu.yuzu_emu.model.DirectoryType
|
||||
import org.yuzu.yuzu_emu.model.GameDir
|
||||
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||
import org.yuzu.yuzu_emu.ui.main.MainActivity
|
||||
|
|
@ -73,8 +76,26 @@ class GameFoldersFragment : Fragment() {
|
|||
|
||||
val mainActivity = requireActivity() as MainActivity
|
||||
binding.buttonAdd.setOnClickListener {
|
||||
// Show a model to choose between Game and External Content
|
||||
val options = arrayOf(
|
||||
getString(R.string.games),
|
||||
getString(R.string.external_content)
|
||||
)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.add_folders)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> { // Game Folder
|
||||
mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
|
||||
}
|
||||
1 -> { // External Content Folder
|
||||
mainActivity.getExternalContentDirectory.launch(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
setInsets()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
|
@ -48,16 +51,68 @@ class AddonViewModel : ViewModel() {
|
|||
?: emptyArray()
|
||||
).toMutableList()
|
||||
patchList.sortBy { it.name }
|
||||
|
||||
// Ensure only one update is enabled
|
||||
ensureSingleUpdateEnabled(patchList)
|
||||
|
||||
removeDuplicates(patchList)
|
||||
|
||||
_patchList.value = patchList
|
||||
isRefreshing.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureSingleUpdateEnabled(patchList: MutableList<Patch>) {
|
||||
val updates = patchList.filter { PatchType.from(it.type) == PatchType.Update }
|
||||
if (updates.size <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
val enabledUpdates = updates.filter { it.enabled }
|
||||
|
||||
if (enabledUpdates.size > 1) {
|
||||
val nandOrSdmcEnabled = enabledUpdates.find {
|
||||
it.name.contains("(NAND)") || it.name.contains("(SDMC)")
|
||||
}
|
||||
|
||||
val updateToKeep = nandOrSdmcEnabled ?: enabledUpdates.first()
|
||||
|
||||
for (patch in patchList) {
|
||||
if (PatchType.from(patch.type) == PatchType.Update) {
|
||||
patch.enabled = (patch === updateToKeep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeDuplicates(patchList: MutableList<Patch>) {
|
||||
val seen = mutableSetOf<String>()
|
||||
val iterator = patchList.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val patch = iterator.next()
|
||||
val key = "${patch.name}|${patch.version}|${patch.type}"
|
||||
if (seen.contains(key)) {
|
||||
iterator.remove()
|
||||
} else {
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAddonToDelete(patch: Patch?) {
|
||||
_addonToDelete.value = patch
|
||||
}
|
||||
|
||||
fun enableOnlyThisUpdate(selectedPatch: Patch) {
|
||||
val currentList = _patchList.value
|
||||
for (patch in currentList) {
|
||||
if (PatchType.from(patch.type) == PatchType.Update) {
|
||||
patch.enabled = (patch === selectedPatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeleteAddon(patch: Patch) {
|
||||
when (PatchType.from(patch.type)) {
|
||||
PatchType.Update -> NativeLibrary.removeUpdate(patch.programId)
|
||||
|
|
@ -72,13 +127,27 @@ class AddonViewModel : ViewModel() {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if there are multiple update versions
|
||||
val updates = _patchList.value.filter { PatchType.from(it.type) == PatchType.Update }
|
||||
val hasMultipleUpdates = updates.size > 1
|
||||
|
||||
NativeConfig.setDisabledAddons(
|
||||
game!!.programId,
|
||||
_patchList.value.mapNotNull {
|
||||
if (it.enabled) {
|
||||
null
|
||||
} else {
|
||||
if (PatchType.from(it.type) == PatchType.Update) {
|
||||
if (it.name.contains("(NAND)") || it.name.contains("(SDMC)")) {
|
||||
it.name
|
||||
} else if (hasMultipleUpdates) {
|
||||
"Update@${it.numericVersion}"
|
||||
} else {
|
||||
it.name
|
||||
}
|
||||
} else {
|
||||
it.name
|
||||
}
|
||||
}
|
||||
}.toTypedArray()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org.yuzu.yuzu_emu.model
|
||||
|
|
@ -56,7 +56,7 @@ class GamesViewModel : ViewModel() {
|
|||
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||
NativeLibrary.reloadKeys()
|
||||
|
||||
getGameDirs()
|
||||
getGameDirsAndExternalContent()
|
||||
reloadGames(directoriesChanged = false, firstStartup = true)
|
||||
}
|
||||
|
||||
|
|
@ -144,11 +144,19 @@ class GamesViewModel : ViewModel() {
|
|||
fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) =
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
when (gameDir.type) {
|
||||
DirectoryType.GAME -> {
|
||||
NativeConfig.addGameDir(gameDir)
|
||||
val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||
.getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true)
|
||||
|
||||
getGameDirs(!isFirstTimeSetup)
|
||||
getGameDirsAndExternalContent(!isFirstTimeSetup)
|
||||
}
|
||||
DirectoryType.EXTERNAL_CONTENT -> {
|
||||
addExternalContentDir(gameDir.uriString)
|
||||
NativeConfig.saveGlobalConfig()
|
||||
getGameDirsAndExternalContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedFromGameFragment) {
|
||||
|
|
@ -168,8 +176,15 @@ class GamesViewModel : ViewModel() {
|
|||
val removedDirIndex = gameDirs.indexOf(gameDir)
|
||||
if (removedDirIndex != -1) {
|
||||
gameDirs.removeAt(removedDirIndex)
|
||||
NativeConfig.setGameDirs(gameDirs.toTypedArray())
|
||||
getGameDirs()
|
||||
when (gameDir.type) {
|
||||
DirectoryType.GAME -> {
|
||||
NativeConfig.setGameDirs(gameDirs.filter { it.type == DirectoryType.GAME }.toTypedArray())
|
||||
}
|
||||
DirectoryType.EXTERNAL_CONTENT -> {
|
||||
removeExternalContentDir(gameDir.uriString)
|
||||
}
|
||||
}
|
||||
getGameDirsAndExternalContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -177,15 +192,16 @@ class GamesViewModel : ViewModel() {
|
|||
fun updateGameDirs() =
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeConfig.setGameDirs(_folders.value.toTypedArray())
|
||||
getGameDirs()
|
||||
val gameDirs = _folders.value.filter { it.type == DirectoryType.GAME }
|
||||
NativeConfig.setGameDirs(gameDirs.toTypedArray())
|
||||
getGameDirsAndExternalContent()
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenGameFoldersFragment() =
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
getGameDirs()
|
||||
getGameDirsAndExternalContent()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,16 +209,36 @@ class GamesViewModel : ViewModel() {
|
|||
NativeConfig.saveGlobalConfig()
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
getGameDirs(true)
|
||||
getGameDirsAndExternalContent(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGameDirs(reloadList: Boolean = false) {
|
||||
val gameDirs = NativeConfig.getGameDirs()
|
||||
_folders.value = gameDirs.toMutableList()
|
||||
private fun getGameDirsAndExternalContent(reloadList: Boolean = false) {
|
||||
val gameDirs = NativeConfig.getGameDirs().toMutableList()
|
||||
val externalContentDirs = NativeConfig.getExternalContentDirs().map {
|
||||
GameDir(it, false, DirectoryType.EXTERNAL_CONTENT)
|
||||
}
|
||||
gameDirs.addAll(externalContentDirs)
|
||||
_folders.value = gameDirs
|
||||
if (reloadList) {
|
||||
reloadGames(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addExternalContentDir(path: String) {
|
||||
val currentDirs = NativeConfig.getExternalContentDirs().toMutableList()
|
||||
if (!currentDirs.contains(path)) {
|
||||
currentDirs.add(path)
|
||||
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
|
||||
NativeConfig.saveGlobalConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeExternalContentDir(path: String) {
|
||||
val currentDirs = NativeConfig.getExternalContentDirs().toMutableList()
|
||||
currentDirs.remove(path)
|
||||
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
|
||||
NativeConfig.saveGlobalConfig()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2025 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org.yuzu.yuzu_emu.utils
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
|
@ -49,6 +49,17 @@ object GameHelper {
|
|||
// Remove previous filesystem provider information so we can get up to date version info
|
||||
NativeLibrary.clearFilesystemProvider()
|
||||
|
||||
// Scan External Content directories and register all NSP/XCI files
|
||||
val externalContentDirs = NativeConfig.getExternalContentDirs()
|
||||
for (externalDir in externalContentDirs) {
|
||||
if (externalDir.isNotEmpty()) {
|
||||
val externalDirUri = externalDir.toUri()
|
||||
if (FileUtil.isTreeUriValid(externalDirUri)) {
|
||||
scanExternalContentRecursive(FileUtil.listFiles(externalDirUri), 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val badDirs = mutableListOf<Int>()
|
||||
gameDirs.forEachIndexed { index: Int, gameDir: GameDir ->
|
||||
val gameDirUri = gameDir.uriString.toUri()
|
||||
|
|
@ -88,6 +99,33 @@ object GameHelper {
|
|||
return games.toList()
|
||||
}
|
||||
|
||||
// File extensions considered as external content, buuut should
|
||||
// be done better imo.
|
||||
private val externalContentExtensions = setOf("nsp", "xci")
|
||||
|
||||
private fun scanExternalContentRecursive(
|
||||
files: Array<MinimalDocumentFile>,
|
||||
depth: Int
|
||||
) {
|
||||
if (depth <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
files.forEach {
|
||||
if (it.isDirectory) {
|
||||
scanExternalContentRecursive(
|
||||
FileUtil.listFiles(it.uri),
|
||||
depth - 1
|
||||
)
|
||||
} else {
|
||||
val extension = FileUtil.getExtension(it.uri).lowercase()
|
||||
if (externalContentExtensions.contains(extension)) {
|
||||
NativeLibrary.addFileToFilesystemProvider(it.uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addGamesRecursive(
|
||||
games: MutableList<Game>,
|
||||
files: Array<MinimalDocumentFile>,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
|
|
@ -204,4 +204,12 @@ object NativeConfig {
|
|||
external fun getSdmcDir(): String
|
||||
@Synchronized
|
||||
external fun setSdmcDir(path: String)
|
||||
|
||||
/**
|
||||
* External Content Provider
|
||||
*/
|
||||
@Synchronized
|
||||
external fun getExternalContentDirs(): Array<String>
|
||||
@Synchronized
|
||||
external fun setExternalContentDirs(dirs: Array<String>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <common/fs/path_util.h>
|
||||
#include <common/logging/log.h>
|
||||
#include <common/settings.h>
|
||||
#include <input_common/main.h>
|
||||
#include "android_config.h"
|
||||
#include "android_settings.h"
|
||||
|
|
@ -69,6 +70,18 @@ void AndroidConfig::ReadPathValues() {
|
|||
}
|
||||
EndArray();
|
||||
|
||||
// Read external content directories
|
||||
Settings::values.external_content_dirs.clear();
|
||||
const int external_dirs_size = BeginArray(std::string("external_content_dirs"));
|
||||
for (int i = 0; i < external_dirs_size; ++i) {
|
||||
SetArrayIndex(i);
|
||||
std::string dir_path = ReadStringSetting(std::string("path"));
|
||||
if (!dir_path.empty()) {
|
||||
Settings::values.external_content_dirs.push_back(dir_path);
|
||||
}
|
||||
}
|
||||
EndArray();
|
||||
|
||||
const auto nand_dir_setting = ReadStringSetting(std::string("nand_directory"));
|
||||
if (!nand_dir_setting.empty()) {
|
||||
Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting);
|
||||
|
|
@ -241,6 +254,14 @@ void AndroidConfig::SavePathValues() {
|
|||
}
|
||||
EndArray();
|
||||
|
||||
// Save external content directories
|
||||
BeginArray(std::string("external_content_dirs"));
|
||||
for (size_t i = 0; i < Settings::values.external_content_dirs.size(); ++i) {
|
||||
SetArrayIndex(i);
|
||||
WriteStringSetting(std::string("path"), Settings::values.external_content_dirs[i]);
|
||||
}
|
||||
EndArray();
|
||||
|
||||
// Save custom NAND directory
|
||||
const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir);
|
||||
WriteStringSetting(std::string("nand_directory"), nand_path,
|
||||
|
|
|
|||
|
|
@ -55,8 +55,11 @@
|
|||
#include "core/crypto/key_manager.h"
|
||||
#include "core/file_sys/card_image.h"
|
||||
#include "core/file_sys/content_archive.h"
|
||||
#include "core/file_sys/control_metadata.h"
|
||||
#include "core/file_sys/fs_filesystem.h"
|
||||
#include "core/file_sys/romfs.h"
|
||||
#include "core/file_sys/nca_metadata.h"
|
||||
#include "core/file_sys/romfs.h"
|
||||
#include "core/file_sys/submission_package.h"
|
||||
#include "core/file_sys/vfs/vfs.h"
|
||||
#include "core/file_sys/vfs/vfs_real.h"
|
||||
|
|
@ -212,6 +215,109 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
|
|||
return;
|
||||
}
|
||||
|
||||
const auto extension = Common::ToLower(filepath.substr(filepath.find_last_of('.') + 1));
|
||||
|
||||
if (extension == "nsp") {
|
||||
auto nsp = std::make_shared<FileSys::NSP>(file);
|
||||
if (nsp->GetStatus() == Loader::ResultStatus::Success) {
|
||||
std::map<u64, u32> nsp_versions;
|
||||
std::map<u64, std::string> nsp_version_strings;
|
||||
|
||||
for (const auto& [title_id, nca_map] : nsp->GetNCAs()) {
|
||||
for (const auto& [type_pair, nca] : nca_map) {
|
||||
const auto& [title_type, content_type] = type_pair;
|
||||
|
||||
if (content_type == FileSys::ContentRecordType::Meta) {
|
||||
const auto meta_nca = std::make_shared<FileSys::NCA>(nca->GetBaseFile());
|
||||
if (meta_nca->GetStatus() == Loader::ResultStatus::Success) {
|
||||
const auto section0 = meta_nca->GetSubdirectories();
|
||||
if (!section0.empty()) {
|
||||
for (const auto& meta_file : section0[0]->GetFiles()) {
|
||||
if (meta_file->GetExtension() == "cnmt") {
|
||||
FileSys::CNMT cnmt(meta_file);
|
||||
nsp_versions[cnmt.GetTitleID()] = cnmt.GetTitleVersion();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content_type == FileSys::ContentRecordType::Control &&
|
||||
title_type == FileSys::TitleType::Update) {
|
||||
auto romfs = nca->GetRomFS();
|
||||
if (romfs) {
|
||||
auto extracted = FileSys::ExtractRomFS(romfs);
|
||||
if (extracted) {
|
||||
auto nacp_file = extracted->GetFile("control.nacp");
|
||||
if (!nacp_file) {
|
||||
nacp_file = extracted->GetFile("Control.nacp");
|
||||
}
|
||||
if (nacp_file) {
|
||||
FileSys::NACP nacp(nacp_file);
|
||||
auto ver_str = nacp.GetVersionString();
|
||||
if (!ver_str.empty()) {
|
||||
nsp_version_strings[title_id] = ver_str;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& [title_id, nca_map] : nsp->GetNCAs()) {
|
||||
for (const auto& [type_pair, nca] : nca_map) {
|
||||
const auto& [title_type, content_type] = type_pair;
|
||||
|
||||
if (title_type == FileSys::TitleType::Update) {
|
||||
u32 version = 0;
|
||||
auto ver_it = nsp_versions.find(title_id);
|
||||
if (ver_it != nsp_versions.end()) {
|
||||
version = ver_it->second;
|
||||
}
|
||||
|
||||
std::string version_string;
|
||||
auto str_it = nsp_version_strings.find(title_id);
|
||||
if (str_it != nsp_version_strings.end()) {
|
||||
version_string = str_it->second;
|
||||
}
|
||||
|
||||
m_manual_provider->AddEntryWithVersion(
|
||||
title_type, content_type, title_id, version, version_string,
|
||||
nca->GetBaseFile());
|
||||
|
||||
LOG_DEBUG(Frontend, "Added NSP update entry - TitleID: {:016X}, Version: {}, VersionStr: {}",
|
||||
title_id, version, version_string);
|
||||
} else {
|
||||
// Use regular AddEntry for non-updates
|
||||
m_manual_provider->AddEntry(title_type, content_type, title_id,
|
||||
nca->GetBaseFile());
|
||||
LOG_DEBUG(Frontend, "Added NSP entry - TitleID: {:016X}, TitleType: {}, ContentType: {}",
|
||||
title_id, static_cast<int>(title_type), static_cast<int>(content_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle XCI files
|
||||
if (extension == "xci") {
|
||||
FileSys::XCI xci{file};
|
||||
if (xci.GetStatus() == Loader::ResultStatus::Success) {
|
||||
const auto nsp = xci.GetSecurePartitionNSP();
|
||||
if (nsp) {
|
||||
for (const auto& title : nsp->GetNCAs()) {
|
||||
for (const auto& entry : title.second) {
|
||||
m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first,
|
||||
entry.second->GetBaseFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
auto loader = Loader::GetLoader(m_system, file);
|
||||
if (!loader) {
|
||||
return;
|
||||
|
|
@ -228,17 +334,6 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
|
|||
m_manual_provider->AddEntry(FileSys::TitleType::Application,
|
||||
FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()),
|
||||
program_id, file);
|
||||
} else if (res2 == Loader::ResultStatus::Success &&
|
||||
(file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) {
|
||||
const auto nsp = file_type == Loader::FileType::NSP
|
||||
? std::make_shared<FileSys::NSP>(file)
|
||||
: FileSys::XCI{file}.GetSecurePartitionNSP();
|
||||
for (const auto& title : nsp->GetNCAs()) {
|
||||
for (const auto& entry : title.second) {
|
||||
m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first,
|
||||
entry.second->GetBaseFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1333,7 +1428,8 @@ jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPatchesForFile(JNIEnv* env
|
|||
Common::Android::ToJString(env, patch.name),
|
||||
Common::Android::ToJString(env, patch.version), static_cast<jint>(patch.type),
|
||||
Common::Android::ToJString(env, std::to_string(patch.program_id)),
|
||||
Common::Android::ToJString(env, std::to_string(patch.title_id)));
|
||||
Common::Android::ToJString(env, std::to_string(patch.title_id)),
|
||||
static_cast<jlong>(patch.numeric_version));
|
||||
env->SetObjectArrayElement(jpatchArray, i, jpatch);
|
||||
++i;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -583,4 +583,26 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSdmcDir(JNIEnv* env, jobject
|
|||
Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, path);
|
||||
}
|
||||
|
||||
jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getExternalContentDirs(JNIEnv* env,
|
||||
jobject obj) {
|
||||
const auto& dirs = Settings::values.external_content_dirs;
|
||||
jobjectArray jdirsArray =
|
||||
env->NewObjectArray(dirs.size(), Common::Android::GetStringClass(),
|
||||
Common::Android::ToJString(env, ""));
|
||||
for (size_t i = 0; i < dirs.size(); ++i) {
|
||||
env->SetObjectArrayElement(jdirsArray, i, Common::Android::ToJString(env, dirs[i]));
|
||||
}
|
||||
return jdirsArray;
|
||||
}
|
||||
|
||||
void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setExternalContentDirs(JNIEnv* env, jobject obj,
|
||||
jobjectArray jdirs) {
|
||||
Settings::values.external_content_dirs.clear();
|
||||
const int size = env->GetArrayLength(jdirs);
|
||||
for (int i = 0; i < size; ++i) {
|
||||
auto jdir = static_cast<jstring>(env->GetObjectArrayElement(jdirs, i));
|
||||
Settings::values.external_content_dirs.push_back(Common::Android::GetJString(env, jdir));
|
||||
}
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
|
|
@ -23,12 +23,25 @@
|
|||
android:layout_gravity="center_vertical|start"
|
||||
android:requiresFadingEdge="horizontal"
|
||||
android:textAlignment="viewStart"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/type_indicator"
|
||||
app:layout_constraintEnd_toStartOf="@+id/button_layout"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="@string/select_gpu_driver_default" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/type_indicator"
|
||||
style="@style/TextAppearance.Material3.LabelSmall"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/button_layout"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/path"
|
||||
tools:text="Games" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_layout"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
|||
|
|
@ -1774,4 +1774,8 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
</string>
|
||||
|
||||
<string name="external_content">External Content</string>
|
||||
<string name="add_folders">Add Folder</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <jni.h>
|
||||
|
|
@ -516,7 +516,7 @@ namespace Common::Android {
|
|||
s_patch_class = reinterpret_cast<jclass>(env->NewGlobalRef(patch_class));
|
||||
s_patch_constructor = env->GetMethodID(
|
||||
patch_class, "<init>",
|
||||
"(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V");
|
||||
"(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;J)V");
|
||||
s_patch_enabled_field = env->GetFieldID(patch_class, "enabled", "Z");
|
||||
s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;");
|
||||
s_patch_version_field = env->GetFieldID(patch_class, "version", "Ljava/lang/String;");
|
||||
|
|
|
|||
|
|
@ -715,6 +715,7 @@ struct Values {
|
|||
Category::DataStorage};
|
||||
Setting<std::string> gamecard_path{linkage, std::string(), "gamecard_path",
|
||||
Category::DataStorage};
|
||||
std::vector<std::string> external_content_dirs;
|
||||
|
||||
// Debugging
|
||||
bool record_frame_times;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
|
||||
|
|
@ -137,12 +137,127 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const {
|
|||
return exefs;
|
||||
|
||||
const auto& disabled = Settings::values.disabled_addons[title_id];
|
||||
const auto update_disabled =
|
||||
std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
|
||||
|
||||
bool update_disabled = true;
|
||||
std::optional<u32> enabled_version;
|
||||
bool checked_external = false;
|
||||
bool checked_manual = false;
|
||||
|
||||
const auto* content_union = dynamic_cast<const ContentProviderUnion*>(&content_provider);
|
||||
const auto update_tid = GetUpdateTitleID(title_id);
|
||||
|
||||
if (content_union) {
|
||||
// First, check ExternalContentProvider
|
||||
const auto* external_provider = content_union->GetExternalProvider();
|
||||
if (external_provider) {
|
||||
const auto update_versions = external_provider->ListUpdateVersions(update_tid);
|
||||
|
||||
if (!update_versions.empty()) {
|
||||
checked_external = true;
|
||||
for (const auto& update_entry : update_versions) {
|
||||
std::string disabled_key = fmt::format("Update@{}", update_entry.version);
|
||||
if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) {
|
||||
update_disabled = false;
|
||||
enabled_version = update_entry.version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check ManualContentProvider (for Android)
|
||||
if (!checked_external) {
|
||||
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
|
||||
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
|
||||
if (manual_provider) {
|
||||
const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid);
|
||||
|
||||
if (!manual_update_versions.empty()) {
|
||||
checked_manual = true;
|
||||
for (const auto& update_entry : manual_update_versions) {
|
||||
std::string disabled_key = fmt::format("Update@{}", update_entry.version);
|
||||
if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) {
|
||||
update_disabled = false;
|
||||
enabled_version = update_entry.version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for original NAND style
|
||||
// Check NAND if: no external updates exist, OR all external updates are disabled
|
||||
if (!checked_external && !checked_manual) {
|
||||
// Only enable NAND update if it exists AND is not disabled
|
||||
// We need to check if an update actually exists in the content provider
|
||||
const bool has_nand_update = content_provider.HasEntry(update_tid, ContentRecordType::Program);
|
||||
|
||||
if (has_nand_update) {
|
||||
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
|
||||
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
|
||||
const bool generic_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
|
||||
|
||||
if (!nand_disabled && !sdmc_disabled && !generic_disabled) {
|
||||
update_disabled = false;
|
||||
}
|
||||
}
|
||||
} else if (update_disabled && content_union) {
|
||||
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
|
||||
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
|
||||
|
||||
if (!nand_disabled || !sdmc_disabled) {
|
||||
const auto nand_sdmc_entries = content_union->ListEntriesFilterOrigin(
|
||||
std::nullopt, TitleType::Update, ContentRecordType::Program, update_tid);
|
||||
|
||||
for (const auto& [slot, entry] : nand_sdmc_entries) {
|
||||
if (slot == ContentProviderUnionSlot::UserNAND ||
|
||||
slot == ContentProviderUnionSlot::SysNAND) {
|
||||
if (!nand_disabled) {
|
||||
update_disabled = false;
|
||||
break;
|
||||
}
|
||||
} else if (slot == ContentProviderUnionSlot::SDMC) {
|
||||
if (!sdmc_disabled) {
|
||||
update_disabled = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game Updates
|
||||
const auto update_tid = GetUpdateTitleID(title_id);
|
||||
const auto update = content_provider.GetEntry(update_tid, ContentRecordType::Program);
|
||||
std::unique_ptr<NCA> update = nullptr;
|
||||
|
||||
// If we have a specific enabled version from external provider, use it
|
||||
if (enabled_version.has_value() && content_union) {
|
||||
const auto* external_provider = content_union->GetExternalProvider();
|
||||
if (external_provider) {
|
||||
auto file = external_provider->GetEntryForVersion(update_tid, ContentRecordType::Program, *enabled_version);
|
||||
if (file != nullptr) {
|
||||
update = std::make_unique<NCA>(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Also try ManualContentProvider
|
||||
if (update == nullptr) {
|
||||
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
|
||||
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
|
||||
if (manual_provider) {
|
||||
auto file = manual_provider->GetEntryForVersion(update_tid, ContentRecordType::Program, *enabled_version);
|
||||
if (file != nullptr) {
|
||||
update = std::make_unique<NCA>(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to regular content provider if no external update was loaded
|
||||
if (update == nullptr && !update_disabled) {
|
||||
update = content_provider.GetEntry(update_tid, ContentRecordType::Program);
|
||||
}
|
||||
|
||||
if (!update_disabled && update != nullptr && update->GetExeFS() != nullptr) {
|
||||
LOG_INFO(Loader, " ExeFS: Update ({}) applied successfully",
|
||||
|
|
@ -447,21 +562,103 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs
|
|||
|
||||
// Game Updates
|
||||
const auto update_tid = GetUpdateTitleID(title_id);
|
||||
const auto update_raw = content_provider.GetEntryRaw(update_tid, type);
|
||||
|
||||
const auto& disabled = Settings::values.disabled_addons[title_id];
|
||||
const auto update_disabled =
|
||||
std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
|
||||
|
||||
bool update_disabled = true;
|
||||
std::optional<u32> enabled_version;
|
||||
VirtualFile update_raw = nullptr;
|
||||
bool checked_external = false;
|
||||
bool checked_manual = false;
|
||||
|
||||
const auto* content_union = dynamic_cast<const ContentProviderUnion*>(&content_provider);
|
||||
if (content_union) {
|
||||
// First, check ExternalContentProvider
|
||||
const auto* external_provider = content_union->GetExternalProvider();
|
||||
if (external_provider) {
|
||||
const auto update_versions = external_provider->ListUpdateVersions(update_tid);
|
||||
|
||||
if (!update_versions.empty()) {
|
||||
checked_external = true;
|
||||
for (const auto& update_entry : update_versions) {
|
||||
std::string disabled_key = fmt::format("Update@{}", update_entry.version);
|
||||
if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) {
|
||||
update_disabled = false;
|
||||
enabled_version = update_entry.version;
|
||||
update_raw = external_provider->GetEntryForVersion(update_tid, type, update_entry.version);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!checked_external) {
|
||||
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
|
||||
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
|
||||
if (manual_provider) {
|
||||
const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid);
|
||||
|
||||
if (!manual_update_versions.empty()) {
|
||||
checked_manual = true;
|
||||
for (const auto& update_entry : manual_update_versions) {
|
||||
std::string disabled_key = fmt::format("Update@{}", update_entry.version);
|
||||
if (std::find(disabled.cbegin(), disabled.cend(), disabled_key) == disabled.cend()) {
|
||||
update_disabled = false;
|
||||
enabled_version = update_entry.version;
|
||||
update_raw = manual_provider->GetEntryForVersion(update_tid, type, update_entry.version);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!checked_external && !checked_manual) {
|
||||
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
|
||||
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
|
||||
const bool generic_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
|
||||
|
||||
if (!nand_disabled && !sdmc_disabled && !generic_disabled) {
|
||||
update_disabled = false;
|
||||
}
|
||||
if (!update_disabled) {
|
||||
update_raw = content_provider.GetEntryRaw(update_tid, type);
|
||||
}
|
||||
} else if (update_disabled && content_union) {
|
||||
const bool nand_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (NAND)") != disabled.cend();
|
||||
const bool sdmc_disabled = std::find(disabled.cbegin(), disabled.cend(), "Update (SDMC)") != disabled.cend();
|
||||
|
||||
if (!nand_disabled || !sdmc_disabled) {
|
||||
const auto nand_sdmc_entries = content_union->ListEntriesFilterOrigin(
|
||||
std::nullopt, TitleType::Update, type, update_tid);
|
||||
|
||||
for (const auto& [slot, entry] : nand_sdmc_entries) {
|
||||
if (slot == ContentProviderUnionSlot::UserNAND ||
|
||||
slot == ContentProviderUnionSlot::SysNAND) {
|
||||
if (!nand_disabled) {
|
||||
update_disabled = false;
|
||||
update_raw = content_provider.GetEntryRaw(update_tid, type);
|
||||
break;
|
||||
}
|
||||
} else if (slot == ContentProviderUnionSlot::SDMC) {
|
||||
if (!sdmc_disabled) {
|
||||
update_disabled = false;
|
||||
update_raw = content_provider.GetEntryRaw(update_tid, type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!update_disabled && update_raw != nullptr && base_nca != nullptr) {
|
||||
const auto new_nca = std::make_shared<NCA>(update_raw, base_nca);
|
||||
if (new_nca->GetStatus() == Loader::ResultStatus::Success &&
|
||||
new_nca->GetRomFS() != nullptr) {
|
||||
LOG_INFO(Loader, " RomFS: Update ({}) applied successfully",
|
||||
enabled_version.has_value() ? FormatTitleVersion(*enabled_version) :
|
||||
FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0)));
|
||||
romfs = new_nca->GetRomFS();
|
||||
const auto version =
|
||||
FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0));
|
||||
}
|
||||
} else if (!update_disabled && packed_update_raw != nullptr && base_nca != nullptr) {
|
||||
const auto new_nca = std::make_shared<NCA>(packed_update_raw, base_nca);
|
||||
|
|
@ -490,18 +687,160 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
|
|||
|
||||
// Game Updates
|
||||
const auto update_tid = GetUpdateTitleID(title_id);
|
||||
|
||||
std::vector<Patch> external_update_patches;
|
||||
|
||||
const auto* content_union = dynamic_cast<const ContentProviderUnion*>(&content_provider);
|
||||
|
||||
if (content_union) {
|
||||
// First, check ExternalContentProvider for updates
|
||||
const auto* external_provider = content_union->GetExternalProvider();
|
||||
if (external_provider) {
|
||||
const auto update_versions = external_provider->ListUpdateVersions(update_tid);
|
||||
|
||||
for (const auto& update_entry : update_versions) {
|
||||
std::string version_str = update_entry.version_string;
|
||||
if (version_str.empty()) {
|
||||
version_str = FormatTitleVersion(update_entry.version);
|
||||
}
|
||||
|
||||
std::string disabled_key = fmt::format("Update@{}", update_entry.version);
|
||||
const auto update_disabled =
|
||||
std::find(disabled.cbegin(), disabled.cend(), disabled_key) != disabled.cend();
|
||||
|
||||
Patch update_patch = {.enabled = !update_disabled,
|
||||
.name = "Update",
|
||||
.version = version_str,
|
||||
.type = PatchType::Update,
|
||||
.program_id = title_id,
|
||||
.title_id = update_tid,
|
||||
.source = PatchSource::External,
|
||||
.numeric_version = update_entry.version};
|
||||
|
||||
external_update_patches.push_back(update_patch);
|
||||
}
|
||||
}
|
||||
|
||||
const auto* manual_provider = dynamic_cast<const ManualContentProvider*>(
|
||||
content_union->GetSlotProvider(ContentProviderUnionSlot::FrontendManual));
|
||||
if (manual_provider && external_update_patches.empty()) {
|
||||
const auto manual_update_versions = manual_provider->ListUpdateVersions(update_tid);
|
||||
|
||||
for (const auto& update_entry : manual_update_versions) {
|
||||
std::string version_str = update_entry.version_string;
|
||||
if (version_str.empty()) {
|
||||
version_str = FormatTitleVersion(update_entry.version);
|
||||
}
|
||||
|
||||
std::string disabled_key = fmt::format("Update@{}", update_entry.version);
|
||||
const auto update_disabled =
|
||||
std::find(disabled.cbegin(), disabled.cend(), disabled_key) != disabled.cend();
|
||||
|
||||
|
||||
Patch update_patch = {.enabled = !update_disabled,
|
||||
.name = "Update",
|
||||
.version = version_str,
|
||||
.type = PatchType::Update,
|
||||
.program_id = title_id,
|
||||
.title_id = update_tid,
|
||||
.source = PatchSource::External,
|
||||
.numeric_version = update_entry.version};
|
||||
|
||||
external_update_patches.push_back(update_patch);
|
||||
}
|
||||
}
|
||||
|
||||
if (external_update_patches.size() > 1) {
|
||||
bool found_enabled = false;
|
||||
for (auto& patch : external_update_patches) {
|
||||
if (patch.enabled) {
|
||||
if (found_enabled) {
|
||||
patch.enabled = false;
|
||||
} else {
|
||||
found_enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& patch : external_update_patches) {
|
||||
out.push_back(std::move(patch));
|
||||
}
|
||||
|
||||
const auto all_updates = content_union->ListEntriesFilterOrigin(
|
||||
std::nullopt, std::nullopt, ContentRecordType::Program, update_tid);
|
||||
|
||||
for (const auto& [slot, entry] : all_updates) {
|
||||
if (slot == ContentProviderUnionSlot::External) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PatchSource source_type = PatchSource::Unknown;
|
||||
std::string source_suffix;
|
||||
|
||||
switch (slot) {
|
||||
case ContentProviderUnionSlot::UserNAND:
|
||||
case ContentProviderUnionSlot::SysNAND:
|
||||
source_type = PatchSource::NAND;
|
||||
source_suffix = " (NAND)";
|
||||
break;
|
||||
case ContentProviderUnionSlot::SDMC:
|
||||
source_type = PatchSource::NAND;
|
||||
source_suffix = " (SDMC)";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
std::string version_str;
|
||||
u32 numeric_ver = 0;
|
||||
PatchManager update{update_tid, fs_controller, content_provider};
|
||||
const auto metadata = update.GetControlMetadata();
|
||||
const auto& nacp = metadata.first;
|
||||
|
||||
const auto update_disabled =
|
||||
if (nacp != nullptr) {
|
||||
version_str = nacp->GetVersionString();
|
||||
}
|
||||
|
||||
const auto meta_ver = content_provider.GetEntryVersion(update_tid);
|
||||
if (meta_ver.has_value()) {
|
||||
numeric_ver = *meta_ver;
|
||||
if (version_str.empty() && numeric_ver != 0) {
|
||||
version_str = FormatTitleVersion(numeric_ver);
|
||||
}
|
||||
}
|
||||
|
||||
std::string patch_name = "Update" + source_suffix;
|
||||
|
||||
bool update_disabled =
|
||||
std::find(disabled.cbegin(), disabled.cend(), patch_name) != disabled.cend();
|
||||
|
||||
Patch update_patch = {.enabled = !update_disabled,
|
||||
.name = patch_name,
|
||||
.version = version_str,
|
||||
.type = PatchType::Update,
|
||||
.program_id = title_id,
|
||||
.title_id = update_tid,
|
||||
.source = source_type,
|
||||
.numeric_version = numeric_ver};
|
||||
|
||||
out.push_back(update_patch);
|
||||
}
|
||||
} else {
|
||||
PatchManager update{update_tid, fs_controller, content_provider};
|
||||
const auto metadata = update.GetControlMetadata();
|
||||
const auto& nacp = metadata.first;
|
||||
|
||||
bool update_disabled =
|
||||
std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
|
||||
Patch update_patch = {.enabled = !update_disabled,
|
||||
.name = "Update",
|
||||
.version = "",
|
||||
.type = PatchType::Update,
|
||||
.program_id = title_id,
|
||||
.title_id = title_id};
|
||||
.title_id = title_id,
|
||||
.source = PatchSource::Unknown,
|
||||
.numeric_version = 0};
|
||||
|
||||
if (nacp != nullptr) {
|
||||
update_patch.version = nacp->GetVersionString();
|
||||
|
|
@ -513,13 +852,16 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
|
|||
out.push_back(update_patch);
|
||||
} else {
|
||||
update_patch.version = FormatTitleVersion(*meta_ver);
|
||||
update_patch.numeric_version = *meta_ver;
|
||||
out.push_back(update_patch);
|
||||
}
|
||||
} else if (update_raw != nullptr) {
|
||||
update_patch.version = "PACKED";
|
||||
update_patch.source = PatchSource::Packed;
|
||||
out.push_back(update_patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// General Mods (LayeredFS and IPS)
|
||||
const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id);
|
||||
|
|
@ -533,7 +875,8 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
|
|||
.version = "Cheats",
|
||||
.type = PatchType::Mod,
|
||||
.program_id = title_id,
|
||||
.title_id = title_id
|
||||
.title_id = title_id,
|
||||
.source = PatchSource::Unknown
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -579,7 +922,8 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
|
|||
.version = types,
|
||||
.type = PatchType::Mod,
|
||||
.program_id = title_id,
|
||||
.title_id = title_id});
|
||||
.title_id = title_id,
|
||||
.source = PatchSource::Unknown});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -603,21 +947,44 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
|
|||
.version = types,
|
||||
.type = PatchType::Mod,
|
||||
.program_id = title_id,
|
||||
.title_id = title_id});
|
||||
.title_id = title_id,
|
||||
.source = PatchSource::Unknown});
|
||||
}
|
||||
}
|
||||
|
||||
// DLC
|
||||
const auto dlc_entries =
|
||||
content_provider.ListEntriesFilter(TitleType::AOC, ContentRecordType::Data);
|
||||
|
||||
std::vector<ContentProviderEntry> dlc_match;
|
||||
dlc_match.reserve(dlc_entries.size());
|
||||
std::copy_if(dlc_entries.begin(), dlc_entries.end(), std::back_inserter(dlc_match),
|
||||
[this](const ContentProviderEntry& entry) {
|
||||
return GetBaseTitleID(entry.title_id) == title_id &&
|
||||
content_provider.GetEntry(entry)->GetStatus() ==
|
||||
Loader::ResultStatus::Success;
|
||||
const auto base_tid = GetBaseTitleID(entry.title_id);
|
||||
const bool matches_base = base_tid == title_id;
|
||||
|
||||
if (!matches_base) {
|
||||
LOG_DEBUG(Loader, "DLC {:016X} base {:016X} doesn't match title {:016X}",
|
||||
entry.title_id, base_tid, title_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto nca = content_provider.GetEntry(entry);
|
||||
if (!nca) {
|
||||
LOG_DEBUG(Loader, "Failed to get NCA for DLC {:016X}", entry.title_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto status = nca->GetStatus();
|
||||
if (status != Loader::ResultStatus::Success) {
|
||||
LOG_DEBUG(Loader, "DLC {:016X} NCA has status {}",
|
||||
entry.title_id, static_cast<int>(status));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!dlc_match.empty()) {
|
||||
// Ensure sorted so DLC IDs show in order.
|
||||
std::sort(dlc_match.begin(), dlc_match.end());
|
||||
|
|
@ -635,7 +1002,8 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
|
|||
.version = std::move(list),
|
||||
.type = PatchType::DLC,
|
||||
.program_id = title_id,
|
||||
.title_id = dlc_match.back().title_id});
|
||||
.title_id = dlc_match.back().title_id,
|
||||
.source = PatchSource::Unknown});
|
||||
}
|
||||
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
|
||||
|
|
@ -13,12 +13,15 @@
|
|||
#include "common/hex_util.h"
|
||||
#include "common/logging/log.h"
|
||||
#include "common/scope_exit.h"
|
||||
#include "common/string_util.h"
|
||||
#include "core/crypto/key_manager.h"
|
||||
#include "core/file_sys/card_image.h"
|
||||
#include "core/file_sys/common_funcs.h"
|
||||
#include "core/file_sys/content_archive.h"
|
||||
#include "core/file_sys/control_metadata.h"
|
||||
#include "core/file_sys/nca_metadata.h"
|
||||
#include "core/file_sys/registered_cache.h"
|
||||
#include "core/file_sys/romfs.h"
|
||||
#include "core/file_sys/submission_package.h"
|
||||
#include "core/file_sys/vfs/vfs_concat.h"
|
||||
#include "core/loader/loader.h"
|
||||
|
|
@ -974,6 +977,22 @@ std::optional<ContentProviderUnionSlot> ContentProviderUnion::GetSlotForEntry(
|
|||
return iter->first;
|
||||
}
|
||||
|
||||
const ExternalContentProvider* ContentProviderUnion::GetExternalProvider() const {
|
||||
auto it = providers.find(ContentProviderUnionSlot::External);
|
||||
if (it != providers.end() && it->second != nullptr) {
|
||||
return dynamic_cast<const ExternalContentProvider*>(it->second);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ContentProvider* ContentProviderUnion::GetSlotProvider(ContentProviderUnionSlot slot) const {
|
||||
auto it = providers.find(slot);
|
||||
if (it != providers.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ManualContentProvider::~ManualContentProvider() = default;
|
||||
|
||||
void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType content_type,
|
||||
|
|
@ -981,8 +1000,51 @@ void ManualContentProvider::AddEntry(TitleType title_type, ContentRecordType con
|
|||
entries.insert_or_assign({title_type, content_type, title_id}, file);
|
||||
}
|
||||
|
||||
void ManualContentProvider::AddEntryWithVersion(TitleType title_type, ContentRecordType content_type,
|
||||
u64 title_id, u32 version,
|
||||
const std::string& version_string, VirtualFile file) {
|
||||
if (title_type == TitleType::Update) {
|
||||
auto it = std::find_if(multi_version_entries.begin(), multi_version_entries.end(),
|
||||
[title_id, version](const ExternalUpdateEntry& entry) {
|
||||
return entry.title_id == title_id && entry.version == version;
|
||||
});
|
||||
|
||||
if (it != multi_version_entries.end()) {
|
||||
// Update existing entry
|
||||
it->files[content_type] = file;
|
||||
if (!version_string.empty()) {
|
||||
it->version_string = version_string;
|
||||
}
|
||||
} else {
|
||||
// Add new entry
|
||||
ExternalUpdateEntry new_entry;
|
||||
new_entry.title_id = title_id;
|
||||
new_entry.version = version;
|
||||
new_entry.version_string = version_string;
|
||||
new_entry.files[content_type] = file;
|
||||
multi_version_entries.push_back(new_entry);
|
||||
}
|
||||
|
||||
auto existing = entries.find({title_type, content_type, title_id});
|
||||
if (existing == entries.end()) {
|
||||
entries.insert_or_assign({title_type, content_type, title_id}, file);
|
||||
} else {
|
||||
// Check if this version is higher
|
||||
for (const auto& entry : multi_version_entries) {
|
||||
if (entry.title_id == title_id && entry.version > version) {
|
||||
return; // Don't replace with lower version
|
||||
}
|
||||
}
|
||||
entries.insert_or_assign({title_type, content_type, title_id}, file);
|
||||
}
|
||||
} else {
|
||||
entries.insert_or_assign({title_type, content_type, title_id}, file);
|
||||
}
|
||||
}
|
||||
|
||||
void ManualContentProvider::ClearAllEntries() {
|
||||
entries.clear();
|
||||
multi_version_entries.clear();
|
||||
}
|
||||
|
||||
void ManualContentProvider::Refresh() {}
|
||||
|
|
@ -1036,4 +1098,459 @@ std::vector<ContentProviderEntry> ManualContentProvider::ListEntriesFilter(
|
|||
out.erase(std::unique(out.begin(), out.end()), out.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<ExternalUpdateEntry> ManualContentProvider::ListUpdateVersions(u64 title_id) const {
|
||||
std::vector<ExternalUpdateEntry> out;
|
||||
|
||||
for (const auto& entry : multi_version_entries) {
|
||||
if (entry.title_id == title_id) {
|
||||
out.push_back(entry);
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const ExternalUpdateEntry& a, const ExternalUpdateEntry& b) {
|
||||
return a.version > b.version;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
VirtualFile ManualContentProvider::GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const {
|
||||
for (const auto& entry : multi_version_entries) {
|
||||
if (entry.title_id == title_id && entry.version == version) {
|
||||
auto it = entry.files.find(type);
|
||||
if (it != entry.files.end()) {
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ManualContentProvider::HasMultipleVersions(u64 title_id, ContentRecordType type) const {
|
||||
int count = 0;
|
||||
for (const auto& entry : multi_version_entries) {
|
||||
if (entry.title_id == title_id && entry.files.count(type) > 0) {
|
||||
count++;
|
||||
if (count > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ExternalContentProvider::ExternalContentProvider(std::vector<VirtualDir> load_directories)
|
||||
: load_dirs(std::move(load_directories)) {
|
||||
ExternalContentProvider::Refresh();
|
||||
}
|
||||
|
||||
ExternalContentProvider::~ExternalContentProvider() = default;
|
||||
|
||||
void ExternalContentProvider::AddDirectory(VirtualDir directory) {
|
||||
if (directory != nullptr) {
|
||||
load_dirs.push_back(std::move(directory));
|
||||
ScanDirectory(load_dirs.back());
|
||||
}
|
||||
}
|
||||
|
||||
void ExternalContentProvider::ClearDirectories() {
|
||||
load_dirs.clear();
|
||||
entries.clear();
|
||||
versions.clear();
|
||||
multi_version_entries.clear();
|
||||
}
|
||||
|
||||
void ExternalContentProvider::Refresh() {
|
||||
entries.clear();
|
||||
versions.clear();
|
||||
multi_version_entries.clear();
|
||||
for (const auto& dir : load_dirs) {
|
||||
if (dir != nullptr) {
|
||||
ScanDirectory(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ExternalContentProvider::ScanDirectory(const VirtualDir& dir) {
|
||||
if (dir == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& file : dir->GetFiles()) {
|
||||
const auto filename = file->GetName();
|
||||
const auto dot_pos = filename.find_last_of('.');
|
||||
|
||||
if (dot_pos == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto extension = Common::ToLower(filename.substr(dot_pos + 1));
|
||||
|
||||
if (extension == "nsp") {
|
||||
ProcessNSP(file);
|
||||
} else if (extension == "xci") {
|
||||
ProcessXCI(file);
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& subdir : dir->GetSubdirectories()) {
|
||||
ScanDirectory(subdir);
|
||||
}
|
||||
}
|
||||
|
||||
void ExternalContentProvider::ProcessNSP(const VirtualFile& file) {
|
||||
auto nsp = NSP(file);
|
||||
if (nsp.GetStatus() != Loader::ResultStatus::Success) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG(Service_FS, "Processing NSP file: {}", file->GetName());
|
||||
|
||||
const auto ncas = nsp.GetNCAs();
|
||||
|
||||
std::map<u64, u32> nsp_versions;
|
||||
std::map<u64, std::string> nsp_version_strings; // title_id -> NACP version string
|
||||
|
||||
for (const auto& [title_id, nca_map] : ncas) {
|
||||
for (const auto& [type_pair, nca] : nca_map) {
|
||||
const auto& [title_type, content_type] = type_pair;
|
||||
|
||||
if (content_type == ContentRecordType::Meta) {
|
||||
const auto subdirs = nca->GetSubdirectories();
|
||||
if (!subdirs.empty()) {
|
||||
const auto section0 = subdirs[0];
|
||||
const auto files = section0->GetFiles();
|
||||
for (const auto& inner_file : files) {
|
||||
if (inner_file->GetExtension() == "cnmt") {
|
||||
const CNMT cnmt(inner_file);
|
||||
const auto cnmt_title_id = cnmt.GetTitleID();
|
||||
const auto version = cnmt.GetTitleVersion();
|
||||
nsp_versions[cnmt_title_id] = version;
|
||||
versions[cnmt_title_id] = version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content_type == ContentRecordType::Control && title_type == TitleType::Update) {
|
||||
auto romfs = nca->GetRomFS();
|
||||
if (romfs) {
|
||||
auto extracted = ExtractRomFS(romfs);
|
||||
if (extracted) {
|
||||
auto nacp_file = extracted->GetFile("control.nacp");
|
||||
if (!nacp_file) {
|
||||
nacp_file = extracted->GetFile("Control.nacp");
|
||||
}
|
||||
if (nacp_file) {
|
||||
NACP nacp(nacp_file);
|
||||
auto ver_str = nacp.GetVersionString();
|
||||
if (!ver_str.empty()) {
|
||||
nsp_version_strings[title_id] = ver_str;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::map<std::pair<u64, u32>, std::map<ContentRecordType, VirtualFile>> version_files;
|
||||
|
||||
for (const auto& [title_id, nca_map] : ncas) {
|
||||
for (const auto& [type_pair, nca] : nca_map) {
|
||||
const auto& [title_type, content_type] = type_pair;
|
||||
|
||||
if (title_type != TitleType::AOC && title_type != TitleType::Update) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto nca_file = nsp.GetNCAFile(title_id, content_type, title_type);
|
||||
if (nca_file != nullptr) {
|
||||
entries[{title_id, content_type, title_type}] = nca_file;
|
||||
|
||||
if (title_type == TitleType::Update) {
|
||||
u32 version = 0;
|
||||
auto ver_it = nsp_versions.find(title_id);
|
||||
if (ver_it != nsp_versions.end()) {
|
||||
version = ver_it->second;
|
||||
}
|
||||
|
||||
version_files[{title_id, version}][content_type] = nca_file;
|
||||
}
|
||||
|
||||
LOG_DEBUG(Service_FS, "Added entry - Title ID: {:016X}, Type: {}, Content: {}",
|
||||
title_id, static_cast<int>(title_type), static_cast<int>(content_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& [key, files_map] : version_files) {
|
||||
const auto& [title_id, version] = key;
|
||||
|
||||
std::string ver_str;
|
||||
auto str_it = nsp_version_strings.find(title_id);
|
||||
if (str_it != nsp_version_strings.end()) {
|
||||
ver_str = str_it->second;
|
||||
}
|
||||
|
||||
bool version_exists = false;
|
||||
for (auto& existing : multi_version_entries) {
|
||||
if (existing.title_id == title_id && existing.version == version) {
|
||||
for (const auto& [content_type, _file] : files_map) {
|
||||
existing.files[content_type] = _file;
|
||||
}
|
||||
if (existing.version_string.empty() && !ver_str.empty()) {
|
||||
existing.version_string = ver_str;
|
||||
}
|
||||
version_exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!version_exists && !files_map.empty()) {
|
||||
ExternalUpdateEntry update_entry{
|
||||
.title_id = title_id,
|
||||
.version = version,
|
||||
.version_string = ver_str,
|
||||
.files = files_map
|
||||
};
|
||||
multi_version_entries.push_back(update_entry);
|
||||
LOG_DEBUG(Service_FS, "Added multi-version update - Title ID: {:016X}, Version: {}, VersionStr: {}, Content types: {}",
|
||||
title_id, version, ver_str, files_map.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ExternalContentProvider::ProcessXCI(const VirtualFile& file) {
|
||||
auto xci = XCI(file);
|
||||
if (xci.GetStatus() != Loader::ResultStatus::Success) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto nsp = xci.GetSecurePartitionNSP();
|
||||
if (nsp == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto ncas = nsp->GetNCAs();
|
||||
|
||||
std::map<u64, u32> xci_versions;
|
||||
std::map<u64, std::string> xci_version_strings;
|
||||
|
||||
for (const auto& [title_id, nca_map] : ncas) {
|
||||
for (const auto& [type_pair, nca] : nca_map) {
|
||||
const auto& [title_type, content_type] = type_pair;
|
||||
|
||||
if (content_type == ContentRecordType::Meta) {
|
||||
const auto subdirs = nca->GetSubdirectories();
|
||||
if (!subdirs.empty()) {
|
||||
const auto section0 = subdirs[0];
|
||||
const auto files = section0->GetFiles();
|
||||
for (const auto& inner_file : files) {
|
||||
if (inner_file->GetExtension() == "cnmt") {
|
||||
const CNMT cnmt(inner_file);
|
||||
const auto cnmt_title_id = cnmt.GetTitleID();
|
||||
const auto version = cnmt.GetTitleVersion();
|
||||
xci_versions[cnmt_title_id] = version;
|
||||
versions[cnmt_title_id] = version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content_type == ContentRecordType::Control && title_type == TitleType::Update) {
|
||||
auto romfs = nca->GetRomFS();
|
||||
if (romfs) {
|
||||
auto extracted = ExtractRomFS(romfs);
|
||||
if (extracted) {
|
||||
auto nacp_file = extracted->GetFile("control.nacp");
|
||||
if (!nacp_file) {
|
||||
nacp_file = extracted->GetFile("Control.nacp");
|
||||
}
|
||||
if (nacp_file) {
|
||||
NACP nacp(nacp_file);
|
||||
auto ver_str = nacp.GetVersionString();
|
||||
if (!ver_str.empty()) {
|
||||
xci_version_strings[title_id] = ver_str;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::map<std::pair<u64, u32>, std::map<ContentRecordType, VirtualFile>> version_files;
|
||||
|
||||
for (const auto& [title_id, nca_map] : ncas) {
|
||||
for (const auto& [type_pair, nca] : nca_map) {
|
||||
const auto& [title_type, content_type] = type_pair;
|
||||
|
||||
if (title_type != TitleType::AOC && title_type != TitleType::Update) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto nca_file = nsp->GetNCAFile(title_id, content_type, title_type);
|
||||
if (nca_file != nullptr) {
|
||||
entries[{title_id, content_type, title_type}] = nca_file;
|
||||
|
||||
if (title_type == TitleType::Update) {
|
||||
u32 version = 0;
|
||||
auto ver_it = xci_versions.find(title_id);
|
||||
if (ver_it != xci_versions.end()) {
|
||||
version = ver_it->second;
|
||||
}
|
||||
|
||||
version_files[{title_id, version}][content_type] = nca_file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& [key, files_map] : version_files) {
|
||||
const auto& [title_id, version] = key;
|
||||
|
||||
std::string ver_str;
|
||||
auto str_it = xci_version_strings.find(title_id);
|
||||
if (str_it != xci_version_strings.end()) {
|
||||
ver_str = str_it->second;
|
||||
}
|
||||
|
||||
bool version_exists = false;
|
||||
for (auto& existing : multi_version_entries) {
|
||||
if (existing.title_id == title_id && existing.version == version) {
|
||||
for (const auto& [content_type, _file] : files_map) {
|
||||
existing.files[content_type] = _file;
|
||||
}
|
||||
if (existing.version_string.empty() && !ver_str.empty()) {
|
||||
existing.version_string = ver_str;
|
||||
}
|
||||
version_exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!version_exists && !files_map.empty()) {
|
||||
ExternalUpdateEntry update_entry{
|
||||
.title_id = title_id,
|
||||
.version = version,
|
||||
.version_string = ver_str,
|
||||
.files = files_map
|
||||
};
|
||||
multi_version_entries.push_back(update_entry);
|
||||
LOG_DEBUG(Service_FS, "Added multi-version update from XCI - Title ID: {:016X}, Version: {}, VersionStr: {}, Content types: {}",
|
||||
title_id, version, ver_str, files_map.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ExternalContentProvider::HasEntry(u64 title_id, ContentRecordType type) const {
|
||||
return GetEntryRaw(title_id, type) != nullptr;
|
||||
}
|
||||
|
||||
std::optional<u32> ExternalContentProvider::GetEntryVersion(u64 title_id) const {
|
||||
const auto it = versions.find(title_id);
|
||||
if (it != versions.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
VirtualFile ExternalContentProvider::GetEntryUnparsed(u64 title_id, ContentRecordType type) const {
|
||||
return GetEntryRaw(title_id, type);
|
||||
}
|
||||
|
||||
VirtualFile ExternalContentProvider::GetEntryRaw(u64 title_id, ContentRecordType type) const {
|
||||
// Try to find in AOC (DLC) entries
|
||||
{
|
||||
const auto it = entries.find({title_id, type, TitleType::AOC});
|
||||
if (it != entries.end()) {
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find in Update entries
|
||||
{
|
||||
const auto it = entries.find({title_id, type, TitleType::Update});
|
||||
if (it != entries.end()) {
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::unique_ptr<NCA> ExternalContentProvider::GetEntry(u64 title_id,
|
||||
ContentRecordType type) const {
|
||||
const auto file = GetEntryRaw(title_id, type);
|
||||
if (file == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
return std::make_unique<NCA>(file);
|
||||
}
|
||||
|
||||
std::vector<ContentProviderEntry> ExternalContentProvider::ListEntriesFilter(
|
||||
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
|
||||
std::optional<u64> title_id) const {
|
||||
std::vector<ContentProviderEntry> out;
|
||||
|
||||
for (const auto& [key, file] : entries) {
|
||||
const auto& [e_title_id, e_content_type, e_title_type] = key;
|
||||
|
||||
if ((title_type == std::nullopt || e_title_type == *title_type) &&
|
||||
(record_type == std::nullopt || e_content_type == *record_type) &&
|
||||
(title_id == std::nullopt || e_title_id == *title_id)) {
|
||||
out.emplace_back(ContentProviderEntry{e_title_id, e_content_type});
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end());
|
||||
out.erase(std::unique(out.begin(), out.end()), out.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<ExternalUpdateEntry> ExternalContentProvider::ListUpdateVersions(u64 title_id) const {
|
||||
std::vector<ExternalUpdateEntry> out;
|
||||
|
||||
for (const auto& entry : multi_version_entries) {
|
||||
if (entry.title_id == title_id) {
|
||||
out.push_back(entry);
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const ExternalUpdateEntry& a, const ExternalUpdateEntry& b) {
|
||||
return a.version > b.version;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
VirtualFile ExternalContentProvider::GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const {
|
||||
for (const auto& entry : multi_version_entries) {
|
||||
if (entry.title_id == title_id && entry.version == version) {
|
||||
auto it = entry.files.find(type);
|
||||
if (it != entry.files.end()) {
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ExternalContentProvider::HasMultipleVersions(u64 title_id, ContentRecordType type) const {
|
||||
size_t count = 0;
|
||||
for (const auto& entry : multi_version_entries) {
|
||||
if (entry.title_id == title_id && entry.files.count(type) > 0) {
|
||||
count++;
|
||||
if (count > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace FileSys
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
|
@ -14,7 +17,8 @@
|
|||
#include "core/file_sys/vfs/vfs.h"
|
||||
|
||||
namespace FileSys {
|
||||
class CNMT;
|
||||
class ExternalContentProvider;
|
||||
class CNMT;
|
||||
class NCA;
|
||||
class NSP;
|
||||
class XCI;
|
||||
|
|
@ -48,6 +52,13 @@ struct ContentProviderEntry {
|
|||
std::string DebugInfo() const;
|
||||
};
|
||||
|
||||
struct ExternalUpdateEntry {
|
||||
u64 title_id;
|
||||
u32 version;
|
||||
std::string version_string;
|
||||
std::map<ContentRecordType, VirtualFile> files;
|
||||
};
|
||||
|
||||
constexpr u64 GetUpdateTitleID(u64 base_title_id) {
|
||||
return base_title_id | 0x800;
|
||||
}
|
||||
|
|
@ -208,6 +219,7 @@ enum class ContentProviderUnionSlot {
|
|||
UserNAND, ///< User NAND
|
||||
SDMC, ///< SD Card
|
||||
FrontendManual, ///< Frontend-defined game list or similar
|
||||
External, ///< External content from NSP/XCI files in configured directories
|
||||
};
|
||||
|
||||
// Combines multiple ContentProvider(s) (i.e. SysNAND, UserNAND, SDMC) into one interface.
|
||||
|
|
@ -228,6 +240,9 @@ public:
|
|||
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
|
||||
std::optional<u64> title_id) const override;
|
||||
|
||||
const ExternalContentProvider* GetExternalProvider() const;
|
||||
const ContentProvider* GetSlotProvider(ContentProviderUnionSlot slot) const;
|
||||
|
||||
std::vector<std::pair<ContentProviderUnionSlot, ContentProviderEntry>> ListEntriesFilterOrigin(
|
||||
std::optional<ContentProviderUnionSlot> origin = {},
|
||||
std::optional<TitleType> title_type = {}, std::optional<ContentRecordType> record_type = {},
|
||||
|
|
@ -246,6 +261,8 @@ public:
|
|||
|
||||
void AddEntry(TitleType title_type, ContentRecordType content_type, u64 title_id,
|
||||
VirtualFile file);
|
||||
void AddEntryWithVersion(TitleType title_type, ContentRecordType content_type, u64 title_id,
|
||||
u32 version, const std::string& version_string, VirtualFile file);
|
||||
void ClearAllEntries();
|
||||
|
||||
void Refresh() override;
|
||||
|
|
@ -258,8 +275,46 @@ public:
|
|||
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
|
||||
std::optional<u64> title_id) const override;
|
||||
|
||||
std::vector<ExternalUpdateEntry> ListUpdateVersions(u64 title_id) const;
|
||||
VirtualFile GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const;
|
||||
bool HasMultipleVersions(u64 title_id, ContentRecordType type) const;
|
||||
|
||||
private:
|
||||
std::map<std::tuple<TitleType, ContentRecordType, u64>, VirtualFile> entries;
|
||||
std::vector<ExternalUpdateEntry> multi_version_entries;
|
||||
};
|
||||
|
||||
class ExternalContentProvider : public ContentProvider {
|
||||
public:
|
||||
explicit ExternalContentProvider(std::vector<VirtualDir> load_directories = {});
|
||||
~ExternalContentProvider() override;
|
||||
|
||||
void AddDirectory(VirtualDir directory);
|
||||
void ClearDirectories();
|
||||
|
||||
void Refresh() override;
|
||||
bool HasEntry(u64 title_id, ContentRecordType type) const override;
|
||||
std::optional<u32> GetEntryVersion(u64 title_id) const override;
|
||||
VirtualFile GetEntryUnparsed(u64 title_id, ContentRecordType type) const override;
|
||||
VirtualFile GetEntryRaw(u64 title_id, ContentRecordType type) const override;
|
||||
std::unique_ptr<NCA> GetEntry(u64 title_id, ContentRecordType type) const override;
|
||||
std::vector<ContentProviderEntry> ListEntriesFilter(
|
||||
std::optional<TitleType> title_type, std::optional<ContentRecordType> record_type,
|
||||
std::optional<u64> title_id) const override;
|
||||
|
||||
std::vector<ExternalUpdateEntry> ListUpdateVersions(u64 title_id) const;
|
||||
VirtualFile GetEntryForVersion(u64 title_id, ContentRecordType type, u32 version) const;
|
||||
bool HasMultipleVersions(u64 title_id, ContentRecordType type) const;
|
||||
|
||||
private:
|
||||
void ScanDirectory(const VirtualDir& dir);
|
||||
void ProcessNSP(const VirtualFile& file);
|
||||
void ProcessXCI(const VirtualFile& file);
|
||||
|
||||
std::vector<VirtualDir> load_dirs;
|
||||
std::map<std::tuple<u64, ContentRecordType, TitleType>, VirtualFile> entries;
|
||||
std::map<u64, u32> versions;
|
||||
std::vector<ExternalUpdateEntry> multi_version_entries;
|
||||
};
|
||||
|
||||
} // namespace FileSys
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
|
@ -275,6 +278,14 @@ void NSP::ReadNCAs(const std::vector<VirtualFile>& files) {
|
|||
ncas[next_nca->GetTitleId()][{cnmt.GetType(), rec.type}] =
|
||||
std::move(next_nca);
|
||||
} else {
|
||||
// fix for Bayonetta Origins in Bayonetta 3 and external content
|
||||
// where multiple update NCAs exist for the same title and type.
|
||||
auto& target_map = ncas[cnmt.GetTitleID()];
|
||||
auto existing = target_map.find({cnmt.GetType(), rec.type});
|
||||
|
||||
if (existing != target_map.end() && rec.type == ContentRecordType::Program) {
|
||||
continue;
|
||||
}
|
||||
ncas[cnmt.GetTitleID()][{cnmt.GetType(), rec.type}] = std::move(next_nca);
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
#include "common/assert.h"
|
||||
#include "common/fs/fs.h"
|
||||
#include "common/fs/path_util.h"
|
||||
#include "common/logging/log.h"
|
||||
#include "common/settings.h"
|
||||
#include "core/core.h"
|
||||
#include "core/file_sys/bis_factory.h"
|
||||
|
|
@ -507,6 +508,10 @@ FileSys::RegisteredCache* FileSystemController::GetSDMCContents() const {
|
|||
return sdmc_factory->GetSDMCContents();
|
||||
}
|
||||
|
||||
FileSys::ExternalContentProvider* FileSystemController::GetExternalContentProvider() const {
|
||||
return external_provider.get();
|
||||
}
|
||||
|
||||
FileSys::PlaceholderCache* FileSystemController::GetSystemNANDPlaceholder() const {
|
||||
LOG_TRACE(Service_FS, "Opening System NAND Placeholder");
|
||||
|
||||
|
|
@ -684,6 +689,7 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove
|
|||
if (overwrite) {
|
||||
bis_factory = nullptr;
|
||||
sdmc_factory = nullptr;
|
||||
external_provider = nullptr;
|
||||
}
|
||||
|
||||
using EdenPath = Common::FS::EdenPath;
|
||||
|
|
@ -716,6 +722,36 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove
|
|||
system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC,
|
||||
sdmc_factory->GetSDMCContents());
|
||||
}
|
||||
|
||||
if (external_provider == nullptr) {
|
||||
std::vector<FileSys::VirtualDir> external_dirs;
|
||||
|
||||
LOG_DEBUG(Service_FS, "Initializing ExternalContentProvider with {} configured directories",
|
||||
Settings::values.external_content_dirs.size());
|
||||
|
||||
for (const auto& dir_path : Settings::values.external_content_dirs) {
|
||||
if (!dir_path.empty()) {
|
||||
LOG_DEBUG(Service_FS, "Attempting to open directory: {}", dir_path);
|
||||
auto dir = vfs.OpenDirectory(dir_path, FileSys::OpenMode::Read);
|
||||
if (dir != nullptr) {
|
||||
external_dirs.push_back(std::move(dir));
|
||||
LOG_DEBUG(Service_FS, "Successfully opened directory: {}", dir_path);
|
||||
} else {
|
||||
LOG_ERROR(Service_FS, "Failed to open directory: {}", dir_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DEBUG(Service_FS, "Creating ExternalContentProvider with {} opened directories",
|
||||
external_dirs.size());
|
||||
|
||||
external_provider = std::make_unique<FileSys::ExternalContentProvider>(
|
||||
std::move(external_dirs));
|
||||
system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::External,
|
||||
external_provider.get());
|
||||
|
||||
LOG_DEBUG(Service_FS, "ExternalContentProvider registered to content provider union");
|
||||
}
|
||||
}
|
||||
|
||||
void FileSystemController::Reset() {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
|
@ -17,6 +20,7 @@ class System;
|
|||
|
||||
namespace FileSys {
|
||||
class BISFactory;
|
||||
class ExternalContentProvider;
|
||||
class NCA;
|
||||
class RegisteredCache;
|
||||
class RegisteredCacheUnion;
|
||||
|
|
@ -117,6 +121,8 @@ public:
|
|||
|
||||
FileSys::VirtualDir GetBCATDirectory(u64 title_id) const;
|
||||
|
||||
FileSys::ExternalContentProvider* GetExternalContentProvider() const;
|
||||
|
||||
// Creates the SaveData, SDMC, and BIS Factories. Should be called once and before any function
|
||||
// above is called.
|
||||
void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true);
|
||||
|
|
@ -138,6 +144,8 @@ private:
|
|||
std::unique_ptr<FileSys::SDMCFactory> sdmc_factory;
|
||||
std::unique_ptr<FileSys::BISFactory> bis_factory;
|
||||
|
||||
std::unique_ptr<FileSys::ExternalContentProvider> external_provider;
|
||||
|
||||
std::unique_ptr<FileSys::XCI> gamecard;
|
||||
std::unique_ptr<FileSys::RegisteredCache> gamecard_registered;
|
||||
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
|
|
@ -231,6 +231,16 @@ void QtConfig::ReadPathValues() {
|
|||
QString::fromStdString(ReadStringSetting(std::string("recentFiles")))
|
||||
.split(QStringLiteral(", "), Qt::SkipEmptyParts, Qt::CaseSensitive);
|
||||
|
||||
const int external_dirs_size = BeginArray(std::string("external_content_dirs"));
|
||||
for (int i = 0; i < external_dirs_size; ++i) {
|
||||
SetArrayIndex(i);
|
||||
std::string dir_path = ReadStringSetting(std::string("path"));
|
||||
if (!dir_path.empty()) {
|
||||
Settings::values.external_content_dirs.push_back(dir_path);
|
||||
}
|
||||
}
|
||||
EndArray();
|
||||
|
||||
ReadCategory(Settings::Category::Paths);
|
||||
|
||||
EndGroup();
|
||||
|
|
@ -446,6 +456,13 @@ void QtConfig::SavePathValues() {
|
|||
WriteStringSetting(std::string("recentFiles"),
|
||||
UISettings::values.recent_files.join(QStringLiteral(", ")).toStdString());
|
||||
|
||||
BeginArray(std::string("external_content_dirs"));
|
||||
for (int i = 0; i < static_cast<int>(Settings::values.external_content_dirs.size()); ++i) {
|
||||
SetArrayIndex(i);
|
||||
WriteStringSetting(std::string("path"), Settings::values.external_content_dirs[i]);
|
||||
}
|
||||
EndArray();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2016 Citra Emulator Project
|
||||
|
|
@ -7,6 +7,9 @@
|
|||
#include <functional>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <QDir>
|
||||
#include <QFileDialog>
|
||||
#include <QListWidget>
|
||||
#include <QMessageBox>
|
||||
#include "common/settings.h"
|
||||
#include "core/core.h"
|
||||
|
|
@ -29,6 +32,15 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
|
|||
connect(ui->button_reset_defaults, &QPushButton::clicked, this,
|
||||
&ConfigureGeneral::ResetDefaults);
|
||||
|
||||
connect(ui->add_external_dir_button, &QPushButton::pressed, this,
|
||||
&ConfigureGeneral::AddExternalContentDirectory);
|
||||
connect(ui->remove_external_dir_button, &QPushButton::pressed, this,
|
||||
&ConfigureGeneral::RemoveSelectedExternalContentDirectory);
|
||||
connect(ui->external_content_list, &QListWidget::itemSelectionChanged, this, [this] {
|
||||
ui->remove_external_dir_button->setEnabled(
|
||||
!ui->external_content_list->selectedItems().isEmpty());
|
||||
});
|
||||
|
||||
if (!Settings::IsConfiguringGlobal()) {
|
||||
ui->button_reset_defaults->setVisible(false);
|
||||
}
|
||||
|
|
@ -36,7 +48,9 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
|
|||
|
||||
ConfigureGeneral::~ConfigureGeneral() = default;
|
||||
|
||||
void ConfigureGeneral::SetConfiguration() {}
|
||||
void ConfigureGeneral::SetConfiguration() {
|
||||
UpdateExternalContentList();
|
||||
}
|
||||
|
||||
void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) {
|
||||
QLayout& general_layout = *ui->general_widget->layout();
|
||||
|
|
@ -101,6 +115,55 @@ void ConfigureGeneral::ApplyConfiguration() {
|
|||
for (const auto& func : apply_funcs) {
|
||||
func(powered_on);
|
||||
}
|
||||
|
||||
std::vector<std::string> new_dirs;
|
||||
new_dirs.reserve(ui->external_content_list->count());
|
||||
for (int i = 0; i < ui->external_content_list->count(); ++i) {
|
||||
new_dirs.push_back(ui->external_content_list->item(i)->text().toStdString());
|
||||
}
|
||||
|
||||
if (new_dirs != Settings::values.external_content_dirs) {
|
||||
Settings::values.external_content_dirs = std::move(new_dirs);
|
||||
emit ExternalContentDirsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigureGeneral::UpdateExternalContentList() {
|
||||
ui->external_content_list->clear();
|
||||
for (const auto& dir : Settings::values.external_content_dirs) {
|
||||
ui->external_content_list->addItem(QString::fromStdString(dir));
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigureGeneral::AddExternalContentDirectory() {
|
||||
const QString dir_path = QFileDialog::getExistingDirectory(
|
||||
this, tr("Select External Content Directory..."), QString());
|
||||
|
||||
if (dir_path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString normalized_path = QDir::toNativeSeparators(dir_path);
|
||||
if (normalized_path.back() != QDir::separator()) {
|
||||
normalized_path.append(QDir::separator());
|
||||
}
|
||||
|
||||
for (int i = 0; i < ui->external_content_list->count(); ++i) {
|
||||
if (ui->external_content_list->item(i)->text() == normalized_path) {
|
||||
QMessageBox::information(this, tr("Directory Already Added"),
|
||||
tr("This directory is already in the list."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ui->external_content_list->addItem(normalized_path);
|
||||
}
|
||||
|
||||
void ConfigureGeneral::RemoveSelectedExternalContentDirectory() {
|
||||
auto selected = ui->external_content_list->selectedItems();
|
||||
if (!selected.isEmpty()) {
|
||||
qDeleteAll(ui->external_content_list->selectedItems());
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigureGeneral::changeEvent(QEvent* event) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2016 Citra Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
|
@ -39,12 +42,19 @@ public:
|
|||
void ApplyConfiguration() override;
|
||||
void SetConfiguration() override;
|
||||
|
||||
signals:
|
||||
void ExternalContentDirsChanged();
|
||||
|
||||
private:
|
||||
void Setup(const ConfigurationShared::Builder& builder);
|
||||
|
||||
void changeEvent(QEvent* event) override;
|
||||
void RetranslateUI();
|
||||
|
||||
void UpdateExternalContentList();
|
||||
void AddExternalContentDirectory();
|
||||
void RemoveSelectedExternalContentDirectory();
|
||||
|
||||
std::function<void()> reset_callback;
|
||||
|
||||
std::unique_ptr<Ui::ConfigureGeneral> ui;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,66 @@
|
|||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_external">
|
||||
<property name="title">
|
||||
<string>External Content</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_external">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_external_desc">
|
||||
<property name="text">
|
||||
<string>Add directories to scan for DLCs and Updates without installing to NAND</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListWidget" name="external_content_list">
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::SingleSelection</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_external_buttons">
|
||||
<item>
|
||||
<widget class="QPushButton" name="add_external_dir_button">
|
||||
<property name="text">
|
||||
<string>Add Directory</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="remove_external_dir_button">
|
||||
<property name="text">
|
||||
<string>Remove Selected</string>
|
||||
</property>
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_external">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include <QHeaderView>
|
||||
#include <QMenu>
|
||||
#include <QStandardItemModel>
|
||||
|
|
@ -16,6 +18,7 @@
|
|||
#include <QTreeView>
|
||||
#include <qstandardpaths.h>
|
||||
|
||||
#include "common/common_types.h"
|
||||
#include "common/fs/fs.h"
|
||||
#include "common/fs/path_util.h"
|
||||
#include "configuration/addon/mod_select_dialog.h"
|
||||
|
|
@ -68,6 +71,8 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
|
|||
|
||||
ui->scrollArea->setEnabled(!system.IsPoweredOn());
|
||||
|
||||
connect(item_model, &QStandardItemModel::itemChanged, this,
|
||||
&ConfigurePerGameAddons::OnItemChanged);
|
||||
connect(item_model, &QStandardItemModel::itemChanged,
|
||||
[] { UISettings::values.is_game_list_reload_pending.exchange(true); });
|
||||
|
||||
|
|
@ -77,14 +82,38 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
|
|||
|
||||
ConfigurePerGameAddons::~ConfigurePerGameAddons() = default;
|
||||
|
||||
void ConfigurePerGameAddons::OnItemChanged(QStandardItem* item) {
|
||||
if (update_items.size() > 1 && item->checkState() == Qt::Checked) {
|
||||
auto it = std::find(update_items.begin(), update_items.end(), item);
|
||||
if (it != update_items.end()) {
|
||||
for (auto* update_item : update_items) {
|
||||
if (update_item != item && update_item->checkState() == Qt::Checked) {
|
||||
disconnect(item_model, &QStandardItemModel::itemChanged, this,
|
||||
&ConfigurePerGameAddons::OnItemChanged);
|
||||
update_item->setCheckState(Qt::Unchecked);
|
||||
connect(item_model, &QStandardItemModel::itemChanged, this,
|
||||
&ConfigurePerGameAddons::OnItemChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigurePerGameAddons::ApplyConfiguration() {
|
||||
std::vector<std::string> disabled_addons;
|
||||
|
||||
for (const auto& item : list_items) {
|
||||
const auto disabled = item.front()->checkState() == Qt::Unchecked;
|
||||
if (disabled)
|
||||
if (disabled) {
|
||||
QVariant userData = item.front()->data(Qt::UserRole);
|
||||
if (userData.isValid() && userData.canConvert<quint32>() && item.front()->text() == QStringLiteral("Update")) {
|
||||
quint32 numeric_version = userData.toUInt();
|
||||
disabled_addons.push_back(fmt::format("Update@{}", numeric_version));
|
||||
} else {
|
||||
disabled_addons.push_back(item.front()->text().toStdString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto current = Settings::values.disabled_addons[title_id];
|
||||
std::sort(disabled_addons.begin(), disabled_addons.end());
|
||||
|
|
@ -194,17 +223,51 @@ void ConfigurePerGameAddons::LoadConfiguration() {
|
|||
|
||||
const auto& disabled = Settings::values.disabled_addons[title_id];
|
||||
|
||||
for (const auto& patch : pm.GetPatches(update_raw)) {
|
||||
update_items.clear();
|
||||
list_items.clear();
|
||||
item_model->removeRows(0, item_model->rowCount());
|
||||
|
||||
std::vector<FileSys::Patch> patches = pm.GetPatches(update_raw);
|
||||
|
||||
bool has_enabled_update = false;
|
||||
|
||||
for (const auto& patch : patches) {
|
||||
const auto name = QString::fromStdString(patch.name);
|
||||
|
||||
auto* const first_item = new QStandardItem;
|
||||
first_item->setText(name);
|
||||
first_item->setCheckable(true);
|
||||
|
||||
const auto patch_disabled =
|
||||
std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end();
|
||||
const bool is_external_update = patch.type == FileSys::PatchType::Update &&
|
||||
patch.source == FileSys::PatchSource::External &&
|
||||
patch.numeric_version != 0;
|
||||
|
||||
first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked);
|
||||
if (is_external_update) {
|
||||
first_item->setData(static_cast<quint32>(patch.numeric_version), Qt::UserRole);
|
||||
}
|
||||
|
||||
bool patch_disabled = false;
|
||||
if (is_external_update) {
|
||||
std::string disabled_key = fmt::format("Update@{}", patch.numeric_version);
|
||||
patch_disabled = std::find(disabled.begin(), disabled.end(), disabled_key) != disabled.end();
|
||||
} else {
|
||||
patch_disabled = std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end();
|
||||
}
|
||||
|
||||
bool should_enable = !patch_disabled;
|
||||
|
||||
if (patch.type == FileSys::PatchType::Update) {
|
||||
if (should_enable) {
|
||||
if (has_enabled_update) {
|
||||
should_enable = false;
|
||||
} else {
|
||||
has_enabled_update = true;
|
||||
}
|
||||
}
|
||||
update_items.push_back(first_item);
|
||||
}
|
||||
|
||||
first_item->setCheckState(should_enable ? Qt::Checked : Qt::Unchecked);
|
||||
|
||||
list_items.push_back(QList<QStandardItem*>{
|
||||
first_item, new QStandardItem{QString::fromStdString(patch.version)}});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ private:
|
|||
void RetranslateUI();
|
||||
|
||||
void LoadConfiguration();
|
||||
void OnItemChanged(QStandardItem* item);
|
||||
|
||||
std::unique_ptr<Ui::ConfigurePerGameAddons> ui;
|
||||
FileSys::VirtualFile file;
|
||||
|
|
@ -64,6 +65,7 @@ private:
|
|||
QStandardItemModel* item_model;
|
||||
|
||||
std::vector<QList<QStandardItem*>> list_items;
|
||||
std::vector<QStandardItem*> update_items;
|
||||
|
||||
Core::System& system;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@
|
|||
#include <QToolButton>
|
||||
#include <QVariantAnimation>
|
||||
#include <fmt/ranges.h>
|
||||
#include <qfilesystemwatcher.h>
|
||||
#include <qnamespace.h>
|
||||
#include <qscroller.h>
|
||||
#include <qscrollerproperties.h>
|
||||
#include "common/common_types.h"
|
||||
#include "common/logging/log.h"
|
||||
#include "common/settings.h"
|
||||
#include "core/core.h"
|
||||
#include "core/file_sys/patch_manager.h"
|
||||
#include "core/file_sys/registered_cache.h"
|
||||
|
|
@ -32,6 +34,7 @@
|
|||
#include "yuzu/game_list_worker.h"
|
||||
#include "yuzu/main_window.h"
|
||||
#include "yuzu/util/controller_navigation.h"
|
||||
#include "qt_common/qt_common.h"
|
||||
|
||||
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent)
|
||||
: QObject(parent), gamelist{gamelist_} {}
|
||||
|
|
@ -325,6 +328,10 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
|
|||
watcher = new QFileSystemWatcher(this);
|
||||
connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory);
|
||||
|
||||
external_watcher = new QFileSystemWatcher(this);
|
||||
ResetExternalWatcher();
|
||||
connect(external_watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshExternalContent);
|
||||
|
||||
this->main_window = parent;
|
||||
layout = new QVBoxLayout;
|
||||
tree_view = new QTreeView;
|
||||
|
|
@ -919,12 +926,38 @@ const QStringList GameList::supported_file_extensions = {
|
|||
|
||||
void GameList::RefreshGameDirectory()
|
||||
{
|
||||
// Reset the externals watcher whenever the game list is reloaded,
|
||||
// primarily ensures that new titles and external dirs are caught.
|
||||
ResetExternalWatcher();
|
||||
|
||||
if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) {
|
||||
LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list.");
|
||||
QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs);
|
||||
PopulateAsync(UISettings::values.game_dirs);
|
||||
}
|
||||
}
|
||||
|
||||
void GameList::RefreshExternalContent() {
|
||||
// TODO: Explore the possibility of only resetting the metadata cache for that specific game.
|
||||
if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) {
|
||||
LOG_INFO(Frontend, "External content directory changed. Clearing metadata cache.");
|
||||
QtCommon::Game::ResetMetadata(false);
|
||||
QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs);
|
||||
PopulateAsync(UISettings::values.game_dirs);
|
||||
}
|
||||
}
|
||||
|
||||
void GameList::ResetExternalWatcher() {
|
||||
auto watch_dirs = external_watcher->directories();
|
||||
if (!watch_dirs.isEmpty()) {
|
||||
external_watcher->removePaths(watch_dirs);
|
||||
}
|
||||
|
||||
for (const std::string &dir : Settings::values.external_content_dirs) {
|
||||
external_watcher->addPath(QString::fromStdString(dir));
|
||||
}
|
||||
}
|
||||
|
||||
void GameList::ToggleFavorite(u64 program_id) {
|
||||
if (!UISettings::values.favorited_ids.contains(program_id)) {
|
||||
tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(),
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ public:
|
|||
|
||||
public slots:
|
||||
void RefreshGameDirectory();
|
||||
void RefreshExternalContent();
|
||||
void ResetExternalWatcher();
|
||||
|
||||
signals:
|
||||
void BootGame(const QString& game_path, StartGameType type);
|
||||
|
|
@ -160,6 +162,7 @@ private:
|
|||
QStandardItemModel* item_model = nullptr;
|
||||
std::unique_ptr<GameListWorker> current_worker;
|
||||
QFileSystemWatcher* watcher = nullptr;
|
||||
QFileSystemWatcher* external_watcher = nullptr;
|
||||
ControllerNavigation* controller_navigation = nullptr;
|
||||
CompatibilityList compatibility_list;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue