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