diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 8136df60e8..13007f10e4 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -29,6 +29,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
+
+
+
String,
+ val currentPathGetter: () -> String,
+ val pathSetter: (String) -> Unit
+) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) {
+
+ override val type = TYPE_PATH
+
+ enum class PathType {
+ SAVE_DATA,
+ NAND,
+ SDMC
+ }
+
+ fun getCurrentPath(): String = currentPathGetter()
+
+ fun getDefaultPath(): String = defaultPathGetter()
+
+ fun setPath(path: String) = pathSetter(path)
+
+ fun isUsingDefaultPath(): Boolean = getCurrentPath() == getDefaultPath()
+
+ companion object {
+ const val TYPE_PATH = 14
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
index ba92ce21ba..da606274eb 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
@@ -98,6 +98,7 @@ abstract class SettingsItem(
const val TYPE_STRING_INPUT = 11
const val TYPE_SPINBOX = 12
const val TYPE_LAUNCHABLE = 13
+ const val TYPE_PATH = 14
const val FASTMEM_COMBINED = "fastmem_combined"
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
index 576af8ece8..fac67dbb64 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
@@ -96,6 +96,10 @@ class SettingsAdapter(
SettingsItem.TYPE_LAUNCHABLE -> {
LaunchableViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
+
+ SettingsItem.TYPE_PATH -> {
+ PathViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
else -> {
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
}
@@ -450,6 +454,18 @@ class SettingsAdapter(
settingsViewModel.setShouldReloadSettingsList(true)
}
+ fun onPathClick(item: PathSetting, position: Int) {
+ settingsViewModel.clickedItem = item
+ settingsViewModel.setPathSettingPosition(position)
+ settingsViewModel.setShouldShowPathPicker(true)
+ }
+
+ fun onPathReset(item: PathSetting, position: Int) {
+ settingsViewModel.clickedItem = item
+ settingsViewModel.setPathSettingPosition(position)
+ settingsViewModel.setShouldShowPathResetDialog(true)
+ }
+
private class DiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean {
return oldItem.setting.key == newItem.setting.key
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
index b2fde638db..2f527b5f62 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
@@ -7,10 +7,16 @@
package org.yuzu.yuzu_emu.features.settings.ui
import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Build
import android.os.Bundle
+import android.os.Environment
+import android.provider.Settings as AndroidSettings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
@@ -19,14 +25,19 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
import org.yuzu.yuzu_emu.features.input.NativeInput
import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.view.PathSetting
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
+import org.yuzu.yuzu_emu.utils.PathUtil
import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins
import org.yuzu.yuzu_emu.utils.*
+import java.io.File
+import androidx.core.net.toUri
class SettingsFragment : Fragment() {
private lateinit var presenter: SettingsFragmentPresenter
@@ -39,6 +50,20 @@ class SettingsFragment : Fragment() {
private val settingsViewModel: SettingsViewModel by activityViewModels()
+ private val requestAllFilesPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ if (hasAllFilesPermission()) {
+ showPathPickerDialog()
+ } else {
+ Toast.makeText(
+ requireContext(),
+ R.string.all_files_permission_required,
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
@@ -134,6 +159,24 @@ class SettingsFragment : Fragment() {
}
}
+ settingsViewModel.shouldShowPathPicker.collect(
+ viewLifecycleOwner,
+ resetState = { settingsViewModel.setShouldShowPathPicker(false) }
+ ) {
+ if (it) {
+ handlePathPickerRequest()
+ }
+ }
+
+ settingsViewModel.shouldShowPathResetDialog.collect(
+ viewLifecycleOwner,
+ resetState = { settingsViewModel.setShouldShowPathResetDialog(false) }
+ ) {
+ if (it) {
+ showPathResetDialog()
+ }
+ }
+
if (args.menuTag == Settings.MenuTag.SECTION_ROOT) {
binding.toolbarSettings.inflateMenu(R.menu.menu_settings)
binding.toolbarSettings.setOnMenuItemClickListener {
@@ -184,4 +227,199 @@ class SettingsFragment : Fragment() {
windowInsets
}
}
+
+ private fun hasAllFilesPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Environment.isExternalStorageManager()
+ } else {
+ true
+ }
+ }
+
+ private fun requestAllFilesPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val intent = Intent(AndroidSettings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
+ intent.data = "package:${requireContext().packageName}".toUri()
+ requestAllFilesPermissionLauncher.launch(intent)
+ }
+ }
+
+ private fun handlePathPickerRequest() {
+ if (!hasAllFilesPermission()) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.all_files_permission_required)
+ .setMessage(R.string.all_files_permission_required)
+ .setPositiveButton(R.string.grant_permission) { _, _ ->
+ requestAllFilesPermission()
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ return
+ }
+ showPathPickerDialog()
+ }
+
+ private fun showPathPickerDialog() {
+ directoryPickerLauncher.launch(null)
+ }
+
+ private val directoryPickerLauncher = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) { uri ->
+ if (uri != null) {
+ val pathSetting = settingsViewModel.clickedItem as? PathSetting ?: return@registerForActivityResult
+ val rawPath = PathUtil.getPathFromUri(uri)
+ if (rawPath != null) {
+ handleSelectedPath(pathSetting, rawPath)
+ } else {
+ Toast.makeText(
+ requireContext(),
+ R.string.invalid_directory,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+
+ private fun handleSelectedPath(pathSetting: PathSetting, path: String) {
+ if (!PathUtil.validateDirectory(path)) {
+ Toast.makeText(
+ requireContext(),
+ R.string.invalid_directory,
+ Toast.LENGTH_SHORT
+ ).show()
+ return
+ }
+
+ if (pathSetting.pathType == PathSetting.PathType.SAVE_DATA) {
+ val oldPath = pathSetting.getCurrentPath()
+ if (oldPath != path) {
+ promptSaveMigration(pathSetting, oldPath, path)
+ }
+ } else {
+ setPathAndNotify(pathSetting, path)
+ }
+ }
+
+ private fun promptSaveMigration(pathSetting: PathSetting, fromPath: String, toPath: String) {
+ val sourceSavePath = "$fromPath/user/save"
+ val destSavePath = "$toPath/user/save"
+ val sourceSaveDir = File(sourceSavePath)
+ val destSaveDir = File(destSavePath)
+
+ val sourceHasSaves = PathUtil.hasContent(sourceSavePath)
+ val destHasSaves = PathUtil.hasContent(destSavePath)
+
+ if (!sourceHasSaves) {
+ setPathAndNotify(pathSetting, toPath)
+ return
+ }
+
+ if (destHasSaves) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.migrate_save_data)
+ .setMessage(R.string.destination_has_saves)
+ .setPositiveButton(R.string.confirm) { _, _ ->
+ migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, toPath)
+ }
+ .setNegativeButton(R.string.skip_migration) { _, _ ->
+ setPathAndNotify(pathSetting, toPath)
+ }
+ .setNeutralButton(R.string.cancel, null)
+ .show()
+ } else {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.migrate_save_data)
+ .setMessage(R.string.migrate_save_data_question)
+ .setPositiveButton(R.string.confirm) { _, _ ->
+ migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, toPath)
+ }
+ .setNegativeButton(R.string.skip_migration) { _, _ ->
+ setPathAndNotify(pathSetting, toPath)
+ }
+ .setNeutralButton(R.string.cancel, null)
+ .show()
+ }
+ }
+
+ private fun migrateSaveData(
+ pathSetting: PathSetting,
+ sourceDir: File,
+ destDir: File,
+ newPath: String
+ ) {
+ Thread {
+ val success = PathUtil.copyDirectory(sourceDir, destDir, overwrite = true)
+
+ requireActivity().runOnUiThread {
+ if (success) {
+ setPathAndNotify(pathSetting, newPath)
+ Toast.makeText(
+ requireContext(),
+ R.string.save_migration_complete,
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ requireContext(),
+ R.string.save_migration_failed,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }.start()
+ }
+
+ private fun setPathAndNotify(pathSetting: PathSetting, path: String) {
+ pathSetting.setPath(path)
+ NativeConfig.saveGlobalConfig()
+
+ NativeConfig.reloadGlobalConfig()
+
+ val messageResId = if (pathSetting.pathType == PathSetting.PathType.SAVE_DATA) {
+ R.string.save_directory_set
+ } else {
+ R.string.path_set
+ }
+
+ Toast.makeText(
+ requireContext(),
+ messageResId,
+ Toast.LENGTH_SHORT
+ ).show()
+
+ val position = settingsViewModel.pathSettingPosition.value
+ if (position >= 0) {
+ settingsAdapter?.notifyItemChanged(position)
+ }
+ }
+
+ private fun showPathResetDialog() {
+ val pathSetting = settingsViewModel.clickedItem as? PathSetting ?: return
+
+ if (pathSetting.isUsingDefaultPath()) {
+ return
+ }
+
+ val currentPath = pathSetting.getCurrentPath()
+ val defaultPath = pathSetting.getDefaultPath()
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.reset_to_nand)
+ .setMessage(R.string.migrate_save_data_question)
+ .setPositiveButton(R.string.confirm) { _, _ ->
+ val sourceSaveDir = File(currentPath, "user/save")
+ val destSaveDir = File(defaultPath, "user/save")
+
+ if (sourceSaveDir.exists() && sourceSaveDir.listFiles()?.isNotEmpty() == true) {
+ migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, defaultPath)
+ } else {
+ setPathAndNotify(pathSetting, defaultPath)
+ }
+ }
+ .setNegativeButton(R.string.cancel) { _, _ ->
+ // just dismiss
+ }
+ .show()
+ }
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
index ca5df58fe8..80b75aed96 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
@@ -29,6 +29,7 @@ import org.yuzu.yuzu_emu.features.settings.model.StringSetting
import org.yuzu.yuzu_emu.features.settings.model.view.*
import org.yuzu.yuzu_emu.utils.InputHandler
import org.yuzu.yuzu_emu.utils.NativeConfig
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
@@ -109,6 +110,7 @@ class SettingsFragmentPresenter(
MenuTag.SECTION_APP_SETTINGS -> addThemeSettings(sl)
MenuTag.SECTION_DEBUG -> addDebugSettings(sl)
MenuTag.SECTION_APPLETS -> addAppletSettings(sl)
+ MenuTag.SECTION_CUSTOM_PATHS -> addCustomPathsSettings(sl)
}
settingsList = sl
adapter.submitList(settingsList) {
@@ -187,6 +189,16 @@ class SettingsFragmentPresenter(
menuKey = MenuTag.SECTION_APPLETS
)
)
+ if (!NativeConfig.isPerGameConfigLoaded()) {
+ add(
+ SubmenuSetting(
+ titleId = R.string.preferences_custom_paths,
+ descriptionId = R.string.preferences_custom_paths_description,
+ iconId = R.drawable.ic_folder_open,
+ menuKey = MenuTag.SECTION_CUSTOM_PATHS
+ )
+ )
+ }
add(
RunnableSetting(
titleId = R.string.reset_to_default,
@@ -1182,4 +1194,42 @@ class SettingsFragmentPresenter(
add(IntSetting.DEBUG_KNOBS.key)
}
}
+
+ private fun addCustomPathsSettings(sl: ArrayList) {
+ sl.apply {
+ add(
+ PathSetting(
+ titleId = R.string.custom_save_directory,
+ descriptionId = R.string.custom_save_directory_description,
+ iconId = R.drawable.ic_save,
+ pathType = PathSetting.PathType.SAVE_DATA,
+ defaultPathGetter = { NativeConfig.getDefaultSaveDir() },
+ currentPathGetter = { NativeConfig.getSaveDir() },
+ pathSetter = { path -> NativeConfig.setSaveDir(path) }
+ )
+ )
+ add(
+ PathSetting(
+ titleId = R.string.custom_nand_directory,
+ descriptionId = R.string.custom_nand_directory_description,
+ iconId = R.drawable.ic_folder_open,
+ pathType = PathSetting.PathType.NAND,
+ defaultPathGetter = { DirectoryInitialization.userDirectory + "/nand" },
+ currentPathGetter = { NativeConfig.getNandDir() },
+ pathSetter = { path -> NativeConfig.setNandDir(path) }
+ )
+ )
+ add(
+ PathSetting(
+ titleId = R.string.custom_sdmc_directory,
+ descriptionId = R.string.custom_sdmc_directory_description,
+ iconId = R.drawable.ic_folder_open,
+ pathType = PathSetting.PathType.SDMC,
+ defaultPathGetter = { DirectoryInitialization.userDirectory + "/sdmc" },
+ currentPathGetter = { NativeConfig.getSdmcDir() },
+ pathSetter = { path -> NativeConfig.setSdmcDir(path) }
+ )
+ )
+ }
+ }
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt
index d47e33244e..b1914c3169 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt
@@ -59,6 +59,16 @@ class SettingsViewModel : ViewModel() {
private val _shouldRecreateForLanguageChange = MutableStateFlow(false)
val shouldRecreateForLanguageChange = _shouldRecreateForLanguageChange.asStateFlow()
+
+ private val _shouldShowPathPicker = MutableStateFlow(false)
+ val shouldShowPathPicker = _shouldShowPathPicker.asStateFlow()
+
+ private val _shouldShowPathResetDialog = MutableStateFlow(false)
+ val shouldShowPathResetDialog = _shouldShowPathResetDialog.asStateFlow()
+
+ private val _pathSettingPosition = MutableStateFlow(-1)
+ val pathSettingPosition = _pathSettingPosition.asStateFlow()
+
fun setShouldRecreate(value: Boolean) {
_shouldRecreate.value = value
}
@@ -112,6 +122,18 @@ class SettingsViewModel : ViewModel() {
_shouldRecreateForLanguageChange.value = value
}
+ fun setShouldShowPathPicker(value: Boolean) {
+ _shouldShowPathPicker.value = value
+ }
+
+ fun setShouldShowPathResetDialog(value: Boolean) {
+ _shouldShowPathResetDialog.value = value
+ }
+
+ fun setPathSettingPosition(value: Int) {
+ _pathSettingPosition.value = value
+ }
+
fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage =
try {
InputHandler.registeredControllers[currentDevice]
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt
new file mode 100644
index 0000000000..7e0517a6dd
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import androidx.core.content.res.ResourcesCompat
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.PathSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import org.yuzu.yuzu_emu.utils.PathUtil
+import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible
+
+class PathViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+
+ private lateinit var setting: PathSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as PathSetting
+ binding.icon.setVisible(setting.iconId != 0)
+ if (setting.iconId != 0) {
+ binding.icon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ binding.icon.resources,
+ setting.iconId,
+ binding.icon.context.theme
+ )
+ )
+ }
+
+ binding.textSettingName.text = setting.title
+ binding.textSettingDescription.setVisible(setting.description.isNotEmpty())
+ binding.textSettingDescription.text = setting.description
+
+ val currentPath = setting.getCurrentPath()
+ val displayPath = PathUtil.truncatePathForDisplay(currentPath)
+
+ binding.textSettingValue.setVisible(true)
+ binding.textSettingValue.text = if (setting.isUsingDefaultPath()) {
+ binding.root.context.getString(R.string.default_string)
+ } else {
+ displayPath
+ }
+
+ binding.buttonClear.setVisible(!setting.isUsingDefaultPath())
+ binding.buttonClear.text = binding.root.context.getString(R.string.reset_to_default)
+ binding.buttonClear.setOnClickListener {
+ adapter.onPathReset(setting, bindingAdapterPosition)
+ }
+
+ setStyle(true, binding)
+ }
+
+ override fun onClick(clicked: View) {
+ adapter.onPathClick(setting, bindingAdapterPosition)
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
index c02411d1bb..1b94d5f1a6 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: 2025 Eden Emulator Project
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.fragments
@@ -31,6 +31,7 @@ import org.yuzu.yuzu_emu.model.TaskState
import org.yuzu.yuzu_emu.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.NativeConfig
import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins
import org.yuzu.yuzu_emu.utils.collect
import java.io.BufferedOutputStream
@@ -99,11 +100,11 @@ class InstallableFragment : Fragment() {
},
export = {
val oldSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(false)
)
val futureSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(true)
)
if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) {
@@ -213,7 +214,7 @@ class InstallableFragment : Fragment() {
}
val internalSaveFolder = File(
- "${DirectoryInitialization.userDirectory}/nand$baseSaveDir"
+ "${NativeConfig.getSaveDir()}$baseSaveDir"
)
internalSaveFolder.deleteRecursively()
internalSaveFolder.mkdir()
@@ -290,7 +291,7 @@ class InstallableFragment : Fragment() {
cacheSaveDir.mkdir()
val oldSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(false)
)
if (oldSaveDataFolder.exists()) {
@@ -298,7 +299,7 @@ class InstallableFragment : Fragment() {
}
val futureSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(true)
)
if (futureSaveDataFolder.exists()) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
index 6859b77806..799708dfa7 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -15,6 +18,7 @@ import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.NativeConfig
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -57,8 +61,7 @@ class Game(
}.zip"
val saveDir: String
- get() = DirectoryInitialization.userDirectory + "/nand" +
- NativeLibrary.getSavePath(programId)
+ get() = NativeConfig.getSaveDir() + NativeLibrary.getSavePath(programId)
val addonDir: String
get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/"
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index 538d8f6e49..23716ac5a5 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -456,7 +456,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
val firmwarePath =
- File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
+ File(NativeConfig.getNandDir() + "/system/Contents/registered/")
val cacheFirmwareDir = File("${cacheDir.path}/registered/")
ProgressDialogFragment.newInstance(
@@ -499,7 +499,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
fun uninstallFirmware() {
val firmwarePath =
- File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
+ File(NativeConfig.getNandDir() + "/system/Contents/registered/")
ProgressDialogFragment.newInstance(
this,
R.string.firmware_uninstalling
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
index 7228f25d24..d53672af26 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -183,4 +186,22 @@ object NativeConfig {
*/
@Synchronized
external fun saveControlPlayerValues()
+
+ /**
+ * Directory paths getters and setters
+ */
+ @Synchronized
+ external fun getSaveDir(): String
+ @Synchronized
+ external fun getDefaultSaveDir(): String
+ @Synchronized
+ external fun setSaveDir(path: String)
+ @Synchronized
+ external fun getNandDir(): String
+ @Synchronized
+ external fun setNandDir(path: String)
+ @Synchronized
+ external fun getSdmcDir(): String
+ @Synchronized
+ external fun setSdmcDir(path: String)
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt
new file mode 100644
index 0000000000..a840b3b846
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.net.Uri
+import android.provider.DocumentsContract
+import java.io.File
+
+object PathUtil {
+
+ /**
+ * Converts a content:// URI from the Storage Access Framework to a real filesystem path.
+ */
+ fun getPathFromUri(uri: Uri): String? {
+ val docId = try {
+ DocumentsContract.getTreeDocumentId(uri)
+ } catch (_: Exception) {
+ return null
+ }
+
+ if (docId.startsWith("primary:")) {
+ val relativePath = docId.substringAfter(":")
+ val primaryStoragePath = android.os.Environment.getExternalStorageDirectory().absolutePath
+ return "$primaryStoragePath/$relativePath"
+ }
+
+ // external SD cards and other volumes)
+ val storageIdString = docId.substringBefore(":")
+ val removablePath = getRemovableStoragePath(storageIdString)
+ if (removablePath != null) {
+ return "$removablePath/${docId.substringAfter(":")}"
+ }
+
+ return null
+ }
+
+ /**
+ * Validates that a path is a valid, writable directory.
+ * Creates the directory if it doesn't exist.
+ */
+ fun validateDirectory(path: String): Boolean {
+ val dir = File(path)
+
+ if (!dir.exists()) {
+ if (!dir.mkdirs()) {
+ return false
+ }
+ }
+
+ return dir.isDirectory && dir.canWrite()
+ }
+
+ /**
+ * Copies a directory recursively from source to destination.
+ */
+ fun copyDirectory(source: File, destination: File, overwrite: Boolean = true): Boolean {
+ return try {
+ source.copyRecursively(destination, overwrite)
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ /**
+ * Checks if a directory has any content.
+ */
+ fun hasContent(path: String): Boolean {
+ val dir = File(path)
+ return dir.exists() && dir.listFiles()?.isNotEmpty() == true
+ }
+
+
+ fun truncatePathForDisplay(path: String, maxLength: Int = 40): String {
+ return if (path.length > maxLength) {
+ "...${path.takeLast(maxLength - 3)}"
+ } else {
+ path
+ }
+ }
+
+ // This really shouldn't be necessary, but the Android API seemingly
+ // doesn't have a way of doing this?
+ // Apparently, on certain devices the mount location can vary, so add
+ // extra cases here if we discover any new ones.
+ fun getRemovableStoragePath(idString: String): String? {
+ var pathFile: File
+
+ pathFile = File("/mnt/media_rw/$idString");
+ if (pathFile.exists()) {
+ return pathFile.absolutePath
+ }
+
+ return null
+ }
+}
diff --git a/src/android/app/src/main/jni/android_config.cpp b/src/android/app/src/main/jni/android_config.cpp
index 41ac680d6b..7345a1893f 100644
--- a/src/android/app/src/main/jni/android_config.cpp
+++ b/src/android/app/src/main/jni/android_config.cpp
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
+#include
#include
#include
#include "android_config.h"
@@ -68,6 +69,24 @@ void AndroidConfig::ReadPathValues() {
}
EndArray();
+ const auto nand_dir_setting = ReadStringSetting(std::string("nand_directory"));
+ if (!nand_dir_setting.empty()) {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting);
+ }
+
+ const auto sdmc_dir_setting = ReadStringSetting(std::string("sdmc_directory"));
+ if (!sdmc_dir_setting.empty()) {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, sdmc_dir_setting);
+ }
+
+ const auto save_dir_setting = ReadStringSetting(std::string("save_directory"));
+ if (save_dir_setting.empty()) {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir));
+ } else {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir, save_dir_setting);
+ }
+
EndGroup();
}
@@ -222,6 +241,26 @@ void AndroidConfig::SavePathValues() {
}
EndArray();
+ // Save custom NAND directory
+ const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir);
+ WriteStringSetting(std::string("nand_directory"), nand_path,
+ std::make_optional(std::string("")));
+
+ // Save custom SDMC directory
+ const auto sdmc_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir);
+ WriteStringSetting(std::string("sdmc_directory"), sdmc_path,
+ std::make_optional(std::string("")));
+
+ // Save custom save directory
+ const auto save_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir);
+ if (save_path == nand_path) {
+ WriteStringSetting(std::string("save_directory"), std::string(""),
+ std::make_optional(std::string("")));
+ } else {
+ WriteStringSetting(std::string("save_directory"), save_path,
+ std::make_optional(std::string("")));
+ }
+
EndGroup();
}
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index 9d2a76566c..d2daef4eb2 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -1389,12 +1389,12 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject j
const auto user_id = manager.GetUser(static_cast(0));
ASSERT(user_id);
- const auto nandDir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir);
- auto vfsNandDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(nandDir),
+ const auto saveDir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir);
+ auto vfsSaveDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(saveDir),
FileSys::OpenMode::Read);
const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
- {}, vfsNandDir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, program_id,
+ {}, vfsSaveDir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, program_id,
user_id->AsU128(), 0);
return Common::Android::ToJString(env, user_save_data_path);
}
diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp
index e6021ed217..800f3e4569 100644
--- a/src/android/app/src/main/jni/native_config.cpp
+++ b/src/android/app/src/main/jni/native_config.cpp
@@ -4,6 +4,7 @@
#include
#include
+#include
#include "android_config.h"
#include "android_settings.h"
@@ -545,4 +546,39 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_saveControlPlayerValues(JNIEnv*
}
}
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getSaveDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir));
+}
+
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getDefaultSaveDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir));
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSaveDir(JNIEnv* env, jobject obj, jstring jpath) {
+ auto path = Common::Android::GetJString(env, jpath);
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir, path);
+}
+
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getNandDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir));
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setNandDir(JNIEnv* env, jobject obj, jstring jpath) {
+ auto path = Common::Android::GetJString(env, jpath);
+ Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, path);
+}
+
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getSdmcDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir));
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSdmcDir(JNIEnv* env, jobject obj, jstring jpath) {
+ auto path = Common::Android::GetJString(env, jpath);
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, path);
+}
+
} // extern "C"
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index f0fef3b1bb..117e7397e0 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -639,6 +639,7 @@
Default
+ Default
Loading…
Shutting down…
Do you want to reset this setting back to its default value?
@@ -714,6 +715,33 @@
Player %d
Debug
CPU/GPU debugging, graphics API, fastmem
+ Custom Paths
+ Save data directory
+
+
+ Save Data Directory
+ Set a custom path for save data storage
+ Select Directory
+ Choose an action for the save directory:
+ Set Custom Path
+ Reset to Default
+ Migrate Save Data
+ Do you want to migrate existing save data to the new location?
+ This will copy your save files from the old location to the new one.
+ Migrating save data…
+ Save data migrated successfully
+ Save data migration failed
+ Save directory set
+ Save directory reset to default
+ The destination already contains data. Do you want to overwrite it?
+ All Files Access permission is required for custom paths
+ Grant Permission
+ NAND Directory
+ Set a custom path for NAND storage
+ SD Card Directory
+ Set a custom path for virtual SD card storage
+ Path set successfully
+ Skip
Info
diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp
index 8f1fe1402e..9b57fda295 100644
--- a/src/common/fs/path_util.cpp
+++ b/src/common/fs/path_util.cpp
@@ -160,6 +160,7 @@ public:
GenerateEdenPath(EdenPath::LogDir, eden_path / LOG_DIR);
GenerateEdenPath(EdenPath::NANDDir, eden_path / NAND_DIR);
GenerateEdenPath(EdenPath::PlayTimeDir, eden_path / PLAY_TIME_DIR);
+ GenerateEdenPath(EdenPath::SaveDir, eden_path / NAND_DIR);
GenerateEdenPath(EdenPath::ScreenshotsDir, eden_path / SCREENSHOTS_DIR);
GenerateEdenPath(EdenPath::SDMCDir, eden_path / SDMC_DIR);
GenerateEdenPath(EdenPath::ShaderDir, eden_path / SHADER_DIR);
diff --git a/src/common/fs/path_util.h b/src/common/fs/path_util.h
index dc800b2892..9f597232a5 100644
--- a/src/common/fs/path_util.h
+++ b/src/common/fs/path_util.h
@@ -25,6 +25,7 @@ enum class EdenPath {
LogDir, // Where log files are stored.
NANDDir, // Where the emulated NAND is stored.
PlayTimeDir, // Where play time data is stored.
+ SaveDir, // Where save data is stored.
ScreenshotsDir, // Where yuzu screenshots are stored.
SDMCDir, // Where the emulated SDMC is stored.
ShaderDir, // Where shaders are stored.
diff --git a/src/common/settings.cpp b/src/common/settings.cpp
index b4eafe5d22..e961e1d2d7 100644
--- a/src/common/settings.cpp
+++ b/src/common/settings.cpp
@@ -138,6 +138,7 @@ void LogSettings() {
log_path("DataStorage_ConfigDir", Common::FS::GetEdenPath(Common::FS::EdenPath::ConfigDir));
log_path("DataStorage_LoadDir", Common::FS::GetEdenPath(Common::FS::EdenPath::LoadDir));
log_path("DataStorage_NANDDir", Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir));
+ log_path("DataStorage_SaveDir", Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir));
log_path("DataStorage_SDMCDir", Common::FS::GetEdenPath(Common::FS::EdenPath::SDMCDir));
}
diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp
index 9d7de4242e..95a32c1250 100644
--- a/src/core/hle/service/filesystem/filesystem.cpp
+++ b/src/core/hle/service/filesystem/filesystem.cpp
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -350,10 +353,10 @@ std::shared_ptr FileSystemController::CreateSaveDataFa
const auto rw_mode = FileSys::OpenMode::ReadWrite;
auto vfs = system.GetFilesystem();
- const auto nand_directory =
- vfs->OpenDirectory(Common::FS::GetEdenPathString(EdenPath::NANDDir), rw_mode);
+ const auto save_directory =
+ vfs->OpenDirectory(Common::FS::GetEdenPathString(EdenPath::SaveDir), rw_mode);
return std::make_shared(system, program_id,
- std::move(nand_directory));
+ std::move(save_directory));
}
Result FileSystemController::OpenSDMC(FileSys::VirtualDir* out_sdmc) const {
diff --git a/src/frontend_common/config.cpp b/src/frontend_common/config.cpp
index 94ec349651..669e578b33 100644
--- a/src/frontend_common/config.cpp
+++ b/src/frontend_common/config.cpp
@@ -290,6 +290,13 @@ void Config::ReadDataStorageValues() {
setPath(EdenPath::DumpDir, "dump_directory");
setPath(EdenPath::TASDir, "tas_directory");
+ const auto save_dir_setting = ReadStringSetting(std::string("save_directory"));
+ if (save_dir_setting.empty()) {
+ SetEdenPath(EdenPath::SaveDir, GetEdenPathString(EdenPath::NANDDir));
+ } else {
+ SetEdenPath(EdenPath::SaveDir, save_dir_setting);
+ }
+
ReadCategory(Settings::Category::DataStorage);
EndGroup();
@@ -583,6 +590,16 @@ void Config::SaveDataStorageValues() {
writePath("dump_directory", EdenPath::DumpDir);
writePath("tas_directory", EdenPath::TASDir);
+ const auto save_path = FS::GetEdenPathString(EdenPath::SaveDir);
+ const auto nand_path = FS::GetEdenPathString(EdenPath::NANDDir);
+ if (save_path == nand_path) {
+ WriteStringSetting(std::string("save_directory"), std::string(""),
+ std::make_optional(std::string("")));
+ } else {
+ WriteStringSetting(std::string("save_directory"), save_path,
+ std::make_optional(std::string("")));
+ }
+
WriteCategory(Settings::Category::DataStorage);
EndGroup();
diff --git a/src/frontend_common/data_manager.cpp b/src/frontend_common/data_manager.cpp
index e5f376720b..1dfbbb0808 100644
--- a/src/frontend_common/data_manager.cpp
+++ b/src/frontend_common/data_manager.cpp
@@ -13,10 +13,11 @@ namespace fs = std::filesystem;
const fs::path GetDataDir(DataDir dir, const std::string &user_id)
{
const fs::path nand_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir);
+ const fs::path save_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir);
switch (dir) {
case DataDir::Saves:
- return (nand_dir / "user" / "save" / "0000000000000000" / user_id).string();
+ return (save_dir / "user" / "save" / "0000000000000000" / user_id).string();
case DataDir::UserNand:
return (nand_dir / "user" / "Contents" / "registered").string();
case DataDir::SysNand:
diff --git a/src/yuzu/configuration/configure_filesystem.cpp b/src/yuzu/configuration/configure_filesystem.cpp
index aae954d21e..545032eee3 100644
--- a/src/yuzu/configuration/configure_filesystem.cpp
+++ b/src/yuzu/configuration/configure_filesystem.cpp
@@ -5,8 +5,10 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "yuzu/configuration/configure_filesystem.h"
+#include
#include
#include
+#include
#include "common/fs/fs.h"
#include "common/fs/path_util.h"
#include "common/settings.h"
@@ -24,6 +26,8 @@ ConfigureFilesystem::ConfigureFilesystem(QWidget* parent)
[this] { SetDirectory(DirectoryTarget::NAND, ui->nand_directory_edit); });
connect(ui->sdmc_directory_button, &QToolButton::pressed, this,
[this] { SetDirectory(DirectoryTarget::SD, ui->sdmc_directory_edit); });
+ connect(ui->save_directory_button, &QToolButton::pressed, this,
+ [this] { SetSaveDirectory(); });
connect(ui->gamecard_path_button, &QToolButton::pressed, this,
[this] { SetDirectory(DirectoryTarget::Gamecard, ui->gamecard_path_edit); });
connect(ui->dump_path_button, &QToolButton::pressed, this,
@@ -55,6 +59,8 @@ void ConfigureFilesystem::SetConfiguration() {
QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir)));
ui->sdmc_directory_edit->setText(
QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir)));
+ ui->save_directory_edit->setText(
+ QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir)));
ui->gamecard_path_edit->setText(
QString::fromStdString(Settings::values.gamecard_path.GetValue()));
ui->dump_path_edit->setText(
@@ -77,6 +83,8 @@ void ConfigureFilesystem::ApplyConfiguration() {
ui->nand_directory_edit->text().toStdString());
Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir,
ui->sdmc_directory_edit->text().toStdString());
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir,
+ ui->save_directory_edit->text().toStdString());
Common::FS::SetEdenPath(Common::FS::EdenPath::DumpDir,
ui->dump_path_edit->text().toStdString());
Common::FS::SetEdenPath(Common::FS::EdenPath::LoadDir,
@@ -100,6 +108,9 @@ void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit)
case DirectoryTarget::SD:
caption = tr("Select Emulated SD Directory...");
break;
+ case DirectoryTarget::Save:
+ caption = tr("Select Save Data Directory...");
+ break;
case DirectoryTarget::Gamecard:
caption = tr("Select Gamecard Path...");
break;
@@ -130,6 +141,131 @@ void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit)
edit->setText(str);
}
+void ConfigureFilesystem::SetSaveDirectory() {
+ const QString current_path = ui->save_directory_edit->text();
+ const QString nand_path = ui->nand_directory_edit->text();
+
+ QMessageBox msgBox(this);
+ msgBox.setWindowTitle(tr("Save Data Directory"));
+ msgBox.setText(tr("Choose an action for the save data directory:"));
+
+ QPushButton* customButton = msgBox.addButton(tr("Set Custom Path"), QMessageBox::ActionRole);
+ QPushButton* resetButton = msgBox.addButton(tr("Reset to NAND"), QMessageBox::ActionRole);
+ msgBox.addButton(QMessageBox::Cancel);
+
+ msgBox.exec();
+
+ if (msgBox.clickedButton() == customButton) {
+ QString new_path = QFileDialog::getExistingDirectory(
+ this, tr("Select Save Data Directory..."), current_path);
+
+ if (new_path.isNull() || new_path.isEmpty()) {
+ return;
+ }
+
+ if (new_path.back() != QChar::fromLatin1('/')) {
+ new_path.append(QChar::fromLatin1('/'));
+ }
+
+ if (new_path != current_path) {
+ PromptSaveMigration(current_path, new_path);
+ ui->save_directory_edit->setText(new_path);
+ }
+ } else if (msgBox.clickedButton() == resetButton) {
+ if (current_path != nand_path) {
+ PromptSaveMigration(current_path, nand_path);
+ ui->save_directory_edit->setText(nand_path);
+ }
+ }
+}
+
+void ConfigureFilesystem::PromptSaveMigration(const QString& from_path, const QString& to_path) {
+ namespace fs = std::filesystem;
+
+ const fs::path source_save_dir = fs::path(from_path.toStdString()) / "user" / "save";
+ const fs::path dest_save_dir = fs::path(to_path.toStdString()) / "user" / "save";
+
+ std::error_code ec;
+
+ bool source_has_saves = false;
+ if (Common::FS::Exists(source_save_dir)) {
+ bool source_empty = fs::is_empty(source_save_dir, ec);
+ source_has_saves = !ec && !source_empty;
+ }
+
+ // Check if destination already has saves
+ bool dest_has_saves = false;
+ if (Common::FS::Exists(dest_save_dir)) {
+ bool dest_empty = fs::is_empty(dest_save_dir, ec);
+ dest_has_saves = !ec && !dest_empty;
+ }
+
+ if (!source_has_saves) {
+ return;
+ }
+
+ QString message;
+ if (dest_has_saves) {
+ message = tr("Save data exists in both the old and new locations.\n\n"
+ "Old: %1\n"
+ "New: %2\n\n"
+ "Would you like to migrate saves from the old location?\n"
+ "WARNING: This will overwrite any conflicting saves in the new location!")
+ .arg(QString::fromStdString(source_save_dir.string()))
+ .arg(QString::fromStdString(dest_save_dir.string()));
+ } else {
+ message = tr("Would you like to migrate your save data to the new location?\n\n"
+ "From: %1\n"
+ "To: %2")
+ .arg(QString::fromStdString(source_save_dir.string()))
+ .arg(QString::fromStdString(dest_save_dir.string()));
+ }
+
+ QMessageBox::StandardButton reply = QMessageBox::question(
+ this, tr("Migrate Save Data"), message,
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
+
+ if (reply != QMessageBox::Yes) {
+ return;
+ }
+
+ QProgressDialog progress(tr("Migrating save data..."), tr("Cancel"), 0, 0, this);
+ progress.setWindowModality(Qt::WindowModal);
+ progress.setMinimumDuration(0);
+ progress.show();
+
+ if (!Common::FS::Exists(dest_save_dir)) {
+ if (!Common::FS::CreateDirs(dest_save_dir)) {
+ progress.close();
+ QMessageBox::warning(this, tr("Migration Failed"),
+ tr("Failed to create destination directory."));
+ return;
+ }
+ }
+
+ fs::copy(source_save_dir, dest_save_dir,
+ fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec);
+
+ progress.close();
+
+ if (ec) {
+ QMessageBox::warning(this, tr("Migration Failed"),
+ tr("Failed to migrate save data:\n%1")
+ .arg(QString::fromStdString(ec.message())));
+ return;
+ }
+
+ QMessageBox::StandardButton deleteReply = QMessageBox::question(
+ this, tr("Migration Complete"),
+ tr("Save data has been migrated successfully.\n\n"
+ "Would you like to delete the old save data?"),
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
+
+ if (deleteReply == QMessageBox::Yes) {
+ Common::FS::RemoveDirRecursively(source_save_dir);
+ }
+}
+
void ConfigureFilesystem::ResetMetadata() {
QtCommon::Game::ResetMetadata();
}
diff --git a/src/yuzu/configuration/configure_filesystem.h b/src/yuzu/configuration/configure_filesystem.h
index 31d2f1d56d..d8c26a783a 100644
--- a/src/yuzu/configuration/configure_filesystem.h
+++ b/src/yuzu/configuration/configure_filesystem.h
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -30,12 +33,15 @@ private:
enum class DirectoryTarget {
NAND,
SD,
+ Save,
Gamecard,
Dump,
Load,
};
void SetDirectory(DirectoryTarget target, QLineEdit* edit);
+ void SetSaveDirectory();
+ void PromptSaveMigration(const QString& from_path, const QString& to_path);
void ResetMetadata();
void UpdateEnabledControls();
diff --git a/src/yuzu/configuration/configure_filesystem.ui b/src/yuzu/configuration/configure_filesystem.ui
index 2f6030b5c4..75c61c74a6 100644
--- a/src/yuzu/configuration/configure_filesystem.ui
+++ b/src/yuzu/configuration/configure_filesystem.ui
@@ -59,6 +59,23 @@
+ -
+
+
+ Save Data
+
+
+
+ -
+
+
+ -
+
+
+ ...
+
+
+
-
diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp
index ed3d0f8466..6d5d6fc03e 100644
--- a/src/yuzu/main_window.cpp
+++ b/src/yuzu/main_window.cpp
@@ -2331,9 +2331,9 @@ void MainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target,
switch (target) {
case GameListOpenTarget::SaveData: {
open_target = tr("Save Data");
- const auto nand_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir);
- auto vfs_nand_dir =
- QtCommon::vfs->OpenDirectory(Common::FS::PathToUTF8String(nand_dir), FileSys::OpenMode::Read);
+ const auto save_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir);
+ auto vfs_save_dir =
+ QtCommon::vfs->OpenDirectory(Common::FS::PathToUTF8String(save_dir), FileSys::OpenMode::Read);
if (has_user_save) {
// User save data
@@ -2341,17 +2341,17 @@ void MainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target,
assert(user_id);
const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
- {}, vfs_nand_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account,
+ {}, vfs_save_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account,
program_id, user_id->AsU128(), 0);
- path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path);
+ path = Common::FS::ConcatPathSafe(save_dir, user_save_data_path);
} else {
// Device save data
const auto device_save_data_path = FileSys::SaveDataFactory::GetFullPath(
- {}, vfs_nand_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account,
+ {}, vfs_save_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account,
program_id, {}, 0);
- path = Common::FS::ConcatPathSafe(nand_dir, device_save_data_path);
+ path = Common::FS::ConcatPathSafe(save_dir, device_save_data_path);
}
if (!Common::FS::CreateDirs(path)) {