Răsfoiți Sursa

android: Add per-game settings

t895 2 ani în urmă
părinte
comite
2fce812026
27 a modificat fișierele cu 303 adăugiri și 83 ștergeri
  1. 11 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
  2. 27 15
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
  3. 6 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
  4. 7 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
  5. 10 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
  6. 12 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt
  7. 1 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt
  8. 2 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt
  9. 12 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt
  10. 12 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt
  11. 1 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt
  12. 13 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
  13. 14 2
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt
  14. 20 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
  15. 6 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt
  16. 5 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
  17. 1 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
  18. 0 4
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt
  19. 2 12
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
  20. 1 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt
  21. 33 9
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
  22. 36 10
      src/android/app/src/main/jni/native_config.cpp
  23. 10 0
      src/android/app/src/main/res/layout/list_item_setting.xml
  24. 48 27
      src/android/app/src/main/res/layout/list_item_setting_switch.xml
  25. 5 0
      src/android/app/src/main/res/menu/menu_in_game.xml
  26. 4 0
      src/android/app/src/main/res/navigation/emulation_navigation.xml
  27. 4 0
      src/android/app/src/main/res/navigation/home_navigation.xml

+ 11 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt

@@ -12,6 +12,7 @@ import org.yuzu.yuzu_emu.features.settings.model.ByteSetting
 import org.yuzu.yuzu_emu.features.settings.model.IntSetting
 import org.yuzu.yuzu_emu.features.settings.model.IntSetting
 import org.yuzu.yuzu_emu.features.settings.model.LongSetting
 import org.yuzu.yuzu_emu.features.settings.model.LongSetting
 import org.yuzu.yuzu_emu.features.settings.model.ShortSetting
 import org.yuzu.yuzu_emu.features.settings.model.ShortSetting
+import org.yuzu.yuzu_emu.utils.NativeConfig
 
 
 /**
 /**
  * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments.
  * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments.
@@ -30,9 +31,19 @@ abstract class SettingsItem(
     val isEditable: Boolean
     val isEditable: Boolean
         get() {
         get() {
             if (!NativeLibrary.isRunning()) return true
             if (!NativeLibrary.isRunning()) return true
+
+            // Prevent editing settings that were modified in per-game config while editing global
+            // config
+            if (!NativeConfig.isPerGameConfigLoaded() && !setting.global) {
+                return false
+            }
             return setting.isRuntimeModifiable
             return setting.isRuntimeModifiable
         }
         }
 
 
+    val needsRuntimeGlobal: Boolean
+        get() = NativeLibrary.isRunning() && !setting.global &&
+            !NativeConfig.isPerGameConfigLoaded()
+
     companion object {
     companion object {
         const val TYPE_HEADER = 0
         const val TYPE_HEADER = 0
         const val TYPE_SWITCH = 1
         const val TYPE_SWITCH = 1

+ 27 - 15
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt

@@ -19,10 +19,9 @@ import androidx.lifecycle.repeatOnLifecycle
 import androidx.navigation.fragment.NavHostFragment
 import androidx.navigation.fragment.NavHostFragment
 import androidx.navigation.navArgs
 import androidx.navigation.navArgs
 import com.google.android.material.color.MaterialColors
 import com.google.android.material.color.MaterialColors
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.NativeLibrary
 import java.io.IOException
 import java.io.IOException
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
 import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
@@ -46,6 +45,9 @@ class SettingsActivity : AppCompatActivity() {
         binding = ActivitySettingsBinding.inflate(layoutInflater)
         binding = ActivitySettingsBinding.inflate(layoutInflater)
         setContentView(binding.root)
         setContentView(binding.root)
 
 
+        if (!NativeConfig.isPerGameConfigLoaded() && args.game != null) {
+            SettingsFile.loadCustomConfig(args.game!!)
+        }
         settingsViewModel.game = args.game
         settingsViewModel.game = args.game
 
 
         val navHostFragment =
         val navHostFragment =
@@ -126,7 +128,6 @@ class SettingsActivity : AppCompatActivity() {
 
 
     override fun onStart() {
     override fun onStart() {
         super.onStart()
         super.onStart()
-        // TODO: Load custom settings contextually
         if (!DirectoryInitialization.areDirectoriesReady) {
         if (!DirectoryInitialization.areDirectoriesReady) {
             DirectoryInitialization.start()
             DirectoryInitialization.start()
         }
         }
@@ -134,24 +135,35 @@ class SettingsActivity : AppCompatActivity() {
 
 
     override fun onStop() {
     override fun onStop() {
         super.onStop()
         super.onStop()
-        CoroutineScope(Dispatchers.IO).launch {
-            NativeConfig.saveSettings()
+        Log.info("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
+        if (isFinishing) {
+            NativeLibrary.applySettings()
+            if (args.game == null) {
+                NativeConfig.saveGlobalConfig()
+            } else if (NativeConfig.isPerGameConfigLoaded()) {
+                NativeLibrary.logSettings()
+                NativeConfig.savePerGameConfig()
+                NativeConfig.unloadPerGameConfig()
+            }
         }
         }
     }
     }
 
 
-    override fun onDestroy() {
-        settingsViewModel.clear()
-        super.onDestroy()
-    }
-
     fun onSettingsReset() {
     fun onSettingsReset() {
         // Delete settings file because the user may have changed values that do not exist in the UI
         // Delete settings file because the user may have changed values that do not exist in the UI
-        NativeConfig.unloadConfig()
-        val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
-        if (!settingsFile.delete()) {
-            throw IOException("Failed to delete $settingsFile")
+        if (args.game == null) {
+            NativeConfig.unloadGlobalConfig()
+            val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
+            if (!settingsFile.delete()) {
+                throw IOException("Failed to delete $settingsFile")
+            }
+            NativeConfig.initializeGlobalConfig()
+        } else {
+            NativeConfig.unloadPerGameConfig()
+            val settingsFile = SettingsFile.getCustomSettingsFile(args.game!!)
+            if (!settingsFile.delete()) {
+                throw IOException("Failed to delete $settingsFile")
+            }
         }
         }
-        NativeConfig.initializeConfig()
 
 
         Toast.makeText(
         Toast.makeText(
             applicationContext,
             applicationContext,

+ 6 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt

@@ -196,6 +196,12 @@ class SettingsAdapter(
         return true
         return true
     }
     }
 
 
+    fun onClearClick(item: SettingsItem, position: Int) {
+        item.setting.global = true
+        notifyItemChanged(position)
+        settingsViewModel.setShouldReloadSettingsList(true)
+    }
+
     private class DiffCallback : DiffUtil.ItemCallback<SettingsItem>() {
     private class DiffCallback : DiffUtil.ItemCallback<SettingsItem>() {
         override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean {
         override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean {
             return oldItem.setting.key == newItem.setting.key
             return oldItem.setting.key == newItem.setting.key

+ 7 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt

@@ -66,7 +66,13 @@ class SettingsFragment : Fragment() {
             args.menuTag
             args.menuTag
         )
         )
 
 
-        binding.toolbarSettingsLayout.title = getString(args.menuTag.titleId)
+        binding.toolbarSettingsLayout.title = if (args.menuTag == Settings.MenuTag.SECTION_ROOT &&
+            args.game != null
+        ) {
+            args.game!!.title
+        } else {
+            getString(args.menuTag.titleId)
+        }
         binding.listSettings.apply {
         binding.listSettings.apply {
             adapter = settingsAdapter
             adapter = settingsAdapter
             layoutManager = LinearLayoutManager(requireContext())
             layoutManager = LinearLayoutManager(requireContext())

+ 10 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt

@@ -7,6 +7,7 @@ import android.content.SharedPreferences
 import android.os.Build
 import android.os.Build
 import android.widget.Toast
 import android.widget.Toast
 import androidx.preference.PreferenceManager
 import androidx.preference.PreferenceManager
+import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.YuzuApplication
 import org.yuzu.yuzu_emu.YuzuApplication
 import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
 import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
@@ -31,9 +32,17 @@ class SettingsFragmentPresenter(
     private val preferences: SharedPreferences
     private val preferences: SharedPreferences
         get() = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
         get() = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
 
 
-    // Extension for populating settings list based on paired settings
+    // Extension for altering settings list based on each setting's properties
     fun ArrayList<SettingsItem>.add(key: String) {
     fun ArrayList<SettingsItem>.add(key: String) {
         val item = SettingsItem.settingsItems[key]!!
         val item = SettingsItem.settingsItems[key]!!
+        if (settingsViewModel.game != null && !item.setting.isSwitchable) {
+            return
+        }
+
+        if (!NativeConfig.isPerGameConfigLoaded() && !NativeLibrary.isRunning()) {
+            item.setting.global = true
+        }
+
         val pairedSettingKey = item.setting.pairedSettingKey
         val pairedSettingKey = item.setting.pairedSettingKey
         if (pairedSettingKey.isNotEmpty()) {
         if (pairedSettingKey.isNotEmpty()) {
             val pairedSettingValue = NativeConfig.getBoolean(
             val pairedSettingValue = NativeConfig.getBoolean(

+ 12 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt

@@ -13,6 +13,7 @@ import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
 import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting
 import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting
 import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
 import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import org.yuzu.yuzu_emu.utils.NativeConfig
 
 
 class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
 class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
     SettingViewHolder(binding.root, adapter) {
     SettingViewHolder(binding.root, adapter) {
@@ -35,6 +36,17 @@ class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsA
         val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
         val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
         binding.textSettingValue.text = dateFormatter.format(zonedTime)
         binding.textSettingValue.text = dateFormatter.format(zonedTime)
 
 
+        binding.buttonClear.visibility = if (setting.setting.global ||
+            !NativeConfig.isPerGameConfigLoaded()
+        ) {
+            View.GONE
+        } else {
+            View.VISIBLE
+        }
+        binding.buttonClear.setOnClickListener {
+            adapter.onClearClick(setting, bindingAdapterPosition)
+        }
+
         setStyle(setting.isEditable, binding)
         setStyle(setting.isEditable, binding)
     }
     }
 
 

+ 1 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt

@@ -38,6 +38,7 @@ class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsA
             binding.textSettingDescription.visibility = View.GONE
             binding.textSettingDescription.visibility = View.GONE
         }
         }
         binding.textSettingValue.visibility = View.GONE
         binding.textSettingValue.visibility = View.GONE
+        binding.buttonClear.visibility = View.GONE
 
 
         setStyle(setting.isEditable, binding)
         setStyle(setting.isEditable, binding)
     }
     }

+ 2 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt

@@ -41,6 +41,7 @@ abstract class SettingViewHolder(itemView: View, protected val adapter: Settings
         binding.textSettingName.alpha = opacity
         binding.textSettingName.alpha = opacity
         binding.textSettingDescription.alpha = opacity
         binding.textSettingDescription.alpha = opacity
         binding.textSettingValue.alpha = opacity
         binding.textSettingValue.alpha = opacity
+        binding.buttonClear.isEnabled = isEditable
     }
     }
 
 
     fun setStyle(isEditable: Boolean, binding: ListItemSettingSwitchBinding) {
     fun setStyle(isEditable: Boolean, binding: ListItemSettingSwitchBinding) {
@@ -48,5 +49,6 @@ abstract class SettingViewHolder(itemView: View, protected val adapter: Settings
         val opacity = if (isEditable) 1.0f else 0.5f
         val opacity = if (isEditable) 1.0f else 0.5f
         binding.textSettingName.alpha = opacity
         binding.textSettingName.alpha = opacity
         binding.textSettingDescription.alpha = opacity
         binding.textSettingDescription.alpha = opacity
+        binding.buttonClear.isEnabled = isEditable
     }
     }
 }
 }

+ 12 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt

@@ -9,6 +9,7 @@ import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
 import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting
 import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting
 import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting
 import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import org.yuzu.yuzu_emu.utils.NativeConfig
 
 
 class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
 class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
     SettingViewHolder(binding.root, adapter) {
     SettingViewHolder(binding.root, adapter) {
@@ -43,6 +44,17 @@ class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: Setti
             }
             }
         }
         }
 
 
+        binding.buttonClear.visibility = if (setting.setting.global ||
+            !NativeConfig.isPerGameConfigLoaded()
+        ) {
+            View.GONE
+        } else {
+            View.VISIBLE
+        }
+        binding.buttonClear.setOnClickListener {
+            adapter.onClearClick(setting, bindingAdapterPosition)
+        }
+
         setStyle(setting.isEditable, binding)
         setStyle(setting.isEditable, binding)
     }
     }
 
 

+ 12 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt

@@ -9,6 +9,7 @@ import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
 import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
 import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
 import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting
 import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import org.yuzu.yuzu_emu.utils.NativeConfig
 
 
 class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
 class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
     SettingViewHolder(binding.root, adapter) {
     SettingViewHolder(binding.root, adapter) {
@@ -30,6 +31,17 @@ class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAda
             setting.units
             setting.units
         )
         )
 
 
+        binding.buttonClear.visibility = if (setting.setting.global ||
+            !NativeConfig.isPerGameConfigLoaded()
+        ) {
+            View.GONE
+        } else {
+            View.VISIBLE
+        }
+        binding.buttonClear.setOnClickListener {
+            adapter.onClearClick(setting, bindingAdapterPosition)
+        }
+
         setStyle(setting.isEditable, binding)
         setStyle(setting.isEditable, binding)
     }
     }
 
 

+ 1 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt

@@ -37,6 +37,7 @@ class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAd
             binding.textSettingDescription.visibility = View.GONE
             binding.textSettingDescription.visibility = View.GONE
         }
         }
         binding.textSettingValue.visibility = View.GONE
         binding.textSettingValue.visibility = View.GONE
+        binding.buttonClear.visibility = View.GONE
     }
     }
 
 
     override fun onClick(clicked: View) {
     override fun onClick(clicked: View) {

+ 13 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt

@@ -9,6 +9,7 @@ import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
 import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
 import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
 import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting
 import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
 import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import org.yuzu.yuzu_emu.utils.NativeConfig
 
 
 class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
 class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
     SettingViewHolder(binding.root, adapter) {
     SettingViewHolder(binding.root, adapter) {
@@ -29,7 +30,18 @@ class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter
         binding.switchWidget.setOnCheckedChangeListener(null)
         binding.switchWidget.setOnCheckedChangeListener(null)
         binding.switchWidget.isChecked = setting.getIsChecked(setting.needsRuntimeGlobal)
         binding.switchWidget.isChecked = setting.getIsChecked(setting.needsRuntimeGlobal)
         binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean ->
         binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean ->
-            adapter.onBooleanClick(item, binding.switchWidget.isChecked)
+            adapter.onBooleanClick(item, binding.switchWidget.isChecked, bindingAdapterPosition)
+        }
+
+        binding.buttonClear.visibility = if (setting.setting.global ||
+            !NativeConfig.isPerGameConfigLoaded()
+        ) {
+            View.GONE
+        } else {
+            View.VISIBLE
+        }
+        binding.buttonClear.setOnClickListener {
+            adapter.onClearClick(setting, bindingAdapterPosition)
         }
         }
 
 
         setStyle(setting.isEditable, binding)
         setStyle(setting.isEditable, binding)

+ 14 - 2
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt

@@ -3,15 +3,27 @@
 
 
 package org.yuzu.yuzu_emu.features.settings.utils
 package org.yuzu.yuzu_emu.features.settings.utils
 
 
+import android.net.Uri
+import org.yuzu.yuzu_emu.model.Game
 import java.io.*
 import java.io.*
 import org.yuzu.yuzu_emu.utils.DirectoryInitialization
 import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.NativeConfig
 
 
 /**
 /**
  * Contains static methods for interacting with .ini files in which settings are stored.
  * Contains static methods for interacting with .ini files in which settings are stored.
  */
  */
 object SettingsFile {
 object SettingsFile {
-    const val FILE_NAME_CONFIG = "config"
+    const val FILE_NAME_CONFIG = "config.ini"
 
 
     fun getSettingsFile(fileName: String): File =
     fun getSettingsFile(fileName: String): File =
-        File(DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini")
+        File(DirectoryInitialization.userDirectory + "/config/" + fileName)
+
+    fun getCustomSettingsFile(game: Game): File =
+        File(DirectoryInitialization.userDirectory + "/config/custom/" + game.settingsName + ".ini")
+
+    fun loadCustomConfig(game: Game) {
+        val fileName = FileUtil.getFilename(Uri.parse(game.path))
+        NativeConfig.initializePerGameConfig(game.programId, fileName)
+    }
 }
 }

+ 20 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt

@@ -52,6 +52,7 @@ import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding
 import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding
 import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding
 import org.yuzu.yuzu_emu.features.settings.model.IntSetting
 import org.yuzu.yuzu_emu.features.settings.model.IntSetting
 import org.yuzu.yuzu_emu.features.settings.model.Settings
 import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
 import org.yuzu.yuzu_emu.model.DriverViewModel
 import org.yuzu.yuzu_emu.model.DriverViewModel
 import org.yuzu.yuzu_emu.model.Game
 import org.yuzu.yuzu_emu.model.Game
 import org.yuzu.yuzu_emu.model.EmulationViewModel
 import org.yuzu.yuzu_emu.model.EmulationViewModel
@@ -127,6 +128,16 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
             return
             return
         }
         }
 
 
+        if (args.custom) {
+            SettingsFile.loadCustomConfig(args.game!!)
+            NativeConfig.unloadPerGameConfig()
+        } else {
+            NativeConfig.reloadGlobalConfig()
+        }
+
+        // Install the selected driver asynchronously as the game starts
+        driverViewModel.onLaunchGame()
+
         // So this fragment doesn't restart on configuration changes; i.e. rotation.
         // So this fragment doesn't restart on configuration changes; i.e. rotation.
         retainInstance = true
         retainInstance = true
         preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
         preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
@@ -217,6 +228,15 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
                     true
                     true
                 }
                 }
 
 
+                R.id.menu_settings_per_game -> {
+                    val action = HomeNavigationDirections.actionGlobalSettingsActivity(
+                        args.game,
+                        Settings.MenuTag.SECTION_ROOT
+                    )
+                    binding.root.findNavController().navigate(action)
+                    true
+                }
+
                 R.id.menu_overlay_controls -> {
                 R.id.menu_overlay_controls -> {
                     showOverlayOptions()
                     showOverlayOptions()
                     true
                     true

+ 6 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt

@@ -13,6 +13,7 @@ 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.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.SerializableHelper.parcelable
 import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
 
 
 class GameFolderPropertiesDialogFragment : DialogFragment() {
 class GameFolderPropertiesDialogFragment : DialogFragment() {
@@ -49,6 +50,11 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
             .show()
             .show()
     }
     }
 
 
+    override fun onStop() {
+        super.onStop()
+        NativeConfig.saveGlobalConfig()
+    }
+
     override fun onSaveInstanceState(outState: Bundle) {
     override fun onSaveInstanceState(outState: Bundle) {
         super.onSaveInstanceState(outState)
         super.onSaveInstanceState(outState)
         outState.putBoolean(DEEP_SCAN, deepScan)
         outState.putBoolean(DEEP_SCAN, deepScan)

+ 5 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt

@@ -304,6 +304,11 @@ class SetupFragment : Fragment() {
         setInsets()
         setInsets()
     }
     }
 
 
+    override fun onStop() {
+        super.onStop()
+        NativeConfig.saveGlobalConfig()
+    }
+
     override fun onSaveInstanceState(outState: Bundle) {
     override fun onSaveInstanceState(outState: Bundle) {
         super.onSaveInstanceState(outState)
         super.onSaveInstanceState(outState)
         if (_binding != null) {
         if (_binding != null) {

+ 1 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt

@@ -168,6 +168,7 @@ class GamesViewModel : ViewModel() {
     fun onCloseGameFoldersFragment() =
     fun onCloseGameFoldersFragment() =
         viewModelScope.launch {
         viewModelScope.launch {
             withContext(Dispatchers.IO) {
             withContext(Dispatchers.IO) {
+                NativeConfig.saveGlobalConfig()
                 getGameDirs(true)
                 getGameDirs(true)
             }
             }
         }
         }

+ 0 - 4
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt

@@ -68,8 +68,4 @@ class SettingsViewModel : ViewModel() {
     fun setAdapterItemChanged(value: Int) {
     fun setAdapterItemChanged(value: Int) {
         _adapterItemChanged.value = value
         _adapterItemChanged.value = value
     }
     }
-
-    fun clear() {
-        game = null
-    }
 }
 }

+ 2 - 12
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt

@@ -28,12 +28,9 @@ import androidx.navigation.ui.setupWithNavController
 import androidx.preference.PreferenceManager
 import androidx.preference.PreferenceManager
 import com.google.android.material.color.MaterialColors
 import com.google.android.material.color.MaterialColors
 import com.google.android.material.navigation.NavigationBarView
 import com.google.android.material.navigation.NavigationBarView
-import kotlinx.coroutines.CoroutineScope
 import java.io.File
 import java.io.File
 import java.io.FilenameFilter
 import java.io.FilenameFilter
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import org.yuzu.yuzu_emu.HomeNavigationDirections
 import org.yuzu.yuzu_emu.HomeNavigationDirections
 import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.R
@@ -258,13 +255,6 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
         super.onResume()
         super.onResume()
     }
     }
 
 
-    override fun onStop() {
-        super.onStop()
-        CoroutineScope(Dispatchers.IO).launch {
-            NativeConfig.saveSettings()
-        }
-    }
-
     override fun onDestroy() {
     override fun onDestroy() {
         EmulationActivity.stopForegroundService(this)
         EmulationActivity.stopForegroundService(this)
         super.onDestroy()
         super.onDestroy()
@@ -677,7 +667,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 }
                 }
 
 
                 // Clear existing user data
                 // Clear existing user data
-                NativeConfig.unloadConfig()
+                NativeConfig.unloadGlobalConfig()
                 File(DirectoryInitialization.userDirectory!!).deleteRecursively()
                 File(DirectoryInitialization.userDirectory!!).deleteRecursively()
 
 
                 // Copy archive to internal storage
                 // Copy archive to internal storage
@@ -696,7 +686,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
 
 
                 // Reinitialize relevant data
                 // Reinitialize relevant data
                 NativeLibrary.initializeSystem(true)
                 NativeLibrary.initializeSystem(true)
-                NativeConfig.initializeConfig()
+                NativeConfig.initializeGlobalConfig()
                 gamesViewModel.reloadGames(false)
                 gamesViewModel.reloadGames(false)
 
 
                 return@newInstance getString(R.string.user_data_import_success)
                 return@newInstance getString(R.string.user_data_import_success)

+ 1 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt

@@ -16,7 +16,7 @@ object DirectoryInitialization {
         if (!areDirectoriesReady) {
         if (!areDirectoriesReady) {
             initializeInternalStorage()
             initializeInternalStorage()
             NativeLibrary.initializeSystem(false)
             NativeLibrary.initializeSystem(false)
-            NativeConfig.initializeConfig()
+            NativeConfig.initializeGlobalConfig()
             areDirectoriesReady = true
             areDirectoriesReady = true
         }
         }
     }
     }

+ 33 - 9
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt

@@ -7,30 +7,54 @@ import org.yuzu.yuzu_emu.model.GameDir
 
 
 object NativeConfig {
 object NativeConfig {
     /**
     /**
-     * Creates a Config object and opens the emulation config.
+     * Loads global config.
      */
      */
     @Synchronized
     @Synchronized
-    external fun initializeConfig()
+    external fun initializeGlobalConfig()
 
 
     /**
     /**
-     * Destroys the stored config object. This automatically saves the existing config.
+     * Destroys the stored global config object. This does not save the existing config.
      */
      */
     @Synchronized
     @Synchronized
-    external fun unloadConfig()
+    external fun unloadGlobalConfig()
 
 
     /**
     /**
-     * Reads values saved to the config file and saves them.
+     * Reads values in the global config file and saves them.
      */
      */
     @Synchronized
     @Synchronized
-    external fun reloadSettings()
+    external fun reloadGlobalConfig()
 
 
     /**
     /**
-     * Saves settings values in memory to disk.
+     * Saves global settings values in memory to disk.
      */
      */
     @Synchronized
     @Synchronized
-    external fun saveSettings()
+    external fun saveGlobalConfig()
 
 
-    external fun getBoolean(key: String, getDefault: Boolean): Boolean
+    /**
+     * Creates per-game config for the specified parameters. Must be unloaded once per-game config
+     * is closed with [unloadPerGameConfig]. All switchable values that [NativeConfig] gets/sets
+     * will follow the per-game config until the global config is reloaded.
+     *
+     * @param programId String representation of the u64 programId
+     * @param fileName Filename of the game, including its extension
+     */
+    @Synchronized
+    external fun initializePerGameConfig(programId: String, fileName: String)
+
+    @Synchronized
+    external fun isPerGameConfigLoaded(): Boolean
+
+    /**
+     * Saves per-game settings values in memory to disk.
+     */
+    @Synchronized
+    external fun savePerGameConfig()
+
+    /**
+     * Destroys the stored per-game config object. This does not save the config.
+     */
+    @Synchronized
+    external fun unloadPerGameConfig()
 
 
     @Synchronized
     @Synchronized
     external fun getBoolean(key: String, needsGlobal: Boolean): Boolean
     external fun getBoolean(key: String, needsGlobal: Boolean): Boolean

+ 36 - 10
src/android/app/src/main/jni/native_config.cpp

@@ -3,6 +3,7 @@
 
 
 #include <string>
 #include <string>
 
 
+#include <common/fs/fs_util.h>
 #include <jni.h>
 #include <jni.h>
 
 
 #include "android_config.h"
 #include "android_config.h"
@@ -12,17 +13,19 @@
 #include "frontend_common/config.h"
 #include "frontend_common/config.h"
 #include "jni/android_common/android_common.h"
 #include "jni/android_common/android_common.h"
 #include "jni/id_cache.h"
 #include "jni/id_cache.h"
+#include "native.h"
 
 
-std::unique_ptr<AndroidConfig> config;
+std::unique_ptr<AndroidConfig> global_config;
+std::unique_ptr<AndroidConfig> per_game_config;
 
 
 template <typename T>
 template <typename T>
 Settings::Setting<T>* getSetting(JNIEnv* env, jstring jkey) {
 Settings::Setting<T>* getSetting(JNIEnv* env, jstring jkey) {
     auto key = GetJString(env, jkey);
     auto key = GetJString(env, jkey);
     auto basicSetting = Settings::values.linkage.by_key[key];
     auto basicSetting = Settings::values.linkage.by_key[key];
-    auto basicAndroidSetting = AndroidSettings::values.linkage.by_key[key];
     if (basicSetting != 0) {
     if (basicSetting != 0) {
         return static_cast<Settings::Setting<T>*>(basicSetting);
         return static_cast<Settings::Setting<T>*>(basicSetting);
     }
     }
+    auto basicAndroidSetting = AndroidSettings::values.linkage.by_key[key];
     if (basicAndroidSetting != 0) {
     if (basicAndroidSetting != 0) {
         return static_cast<Settings::Setting<T>*>(basicAndroidSetting);
         return static_cast<Settings::Setting<T>*>(basicAndroidSetting);
     }
     }
@@ -32,20 +35,43 @@ Settings::Setting<T>* getSetting(JNIEnv* env, jstring jkey) {
 
 
 extern "C" {
 extern "C" {
 
 
-void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_initializeConfig(JNIEnv* env, jobject obj) {
-    config = std::make_unique<AndroidConfig>();
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_initializeGlobalConfig(JNIEnv* env, jobject obj) {
+    global_config = std::make_unique<AndroidConfig>();
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_unloadGlobalConfig(JNIEnv* env, jobject obj) {
+    global_config.reset();
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_reloadGlobalConfig(JNIEnv* env, jobject obj) {
+    global_config->AndroidConfig::ReloadAllValues();
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_saveGlobalConfig(JNIEnv* env, jobject obj) {
+    global_config->AndroidConfig::SaveAllValues();
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_initializePerGameConfig(JNIEnv* env, jobject obj,
+                                                                        jstring jprogramId,
+                                                                        jstring jfileName) {
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    auto file_name = GetJString(env, jfileName);
+    const auto config_file_name = program_id == 0 ? file_name : fmt::format("{:016X}", program_id);
+    per_game_config =
+        std::make_unique<AndroidConfig>(config_file_name, Config::ConfigType::PerGameConfig);
 }
 }
 
 
-void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_unloadConfig(JNIEnv* env, jobject obj) {
-    config.reset();
+jboolean Java_org_yuzu_yuzu_1emu_utils_NativeConfig_isPerGameConfigLoaded(JNIEnv* env,
+                                                                          jobject obj) {
+    return per_game_config != nullptr;
 }
 }
 
 
-void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_reloadSettings(JNIEnv* env, jobject obj) {
-    config->AndroidConfig::ReloadAllValues();
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_savePerGameConfig(JNIEnv* env, jobject obj) {
+    per_game_config->AndroidConfig::SaveAllValues();
 }
 }
 
 
-void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_saveSettings(JNIEnv* env, jobject obj) {
-    config->AndroidConfig::SaveAllValues();
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_unloadPerGameConfig(JNIEnv* env, jobject obj) {
+    per_game_config.reset();
 }
 }
 
 
 jboolean Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getBoolean(JNIEnv* env, jobject obj,
 jboolean Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getBoolean(JNIEnv* env, jobject obj,

+ 10 - 0
src/android/app/src/main/res/layout/list_item_setting.xml

@@ -62,6 +62,16 @@
                 android:textSize="13sp"
                 android:textSize="13sp"
                 tools:text="1x" />
                 tools:text="1x" />
 
 
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/button_clear"
+                style="@style/Widget.Material3.Button.TonalButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="16dp"
+                android:visibility="gone"
+                android:text="@string/clear"
+                tools:visibility="visible" />
+
         </LinearLayout>
         </LinearLayout>
 
 
     </LinearLayout>
     </LinearLayout>

+ 48 - 27
src/android/app/src/main/res/layout/list_item_setting_switch.xml

@@ -10,41 +10,62 @@
     android:minHeight="72dp"
     android:minHeight="72dp"
     android:padding="16dp">
     android:padding="16dp">
 
 
-    <com.google.android.material.materialswitch.MaterialSwitch
-        android:id="@+id/switch_widget"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_alignParentEnd="true"
-        android:layout_centerVertical="true" />
-
     <LinearLayout
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_alignParentTop="true"
-        android:layout_centerVertical="true"
-        android:layout_marginEnd="24dp"
-        android:layout_toStartOf="@+id/switch_widget"
-        android:gravity="center_vertical"
         android:orientation="vertical">
         android:orientation="vertical">
 
 
-        <com.google.android.material.textview.MaterialTextView
-            android:id="@+id/text_setting_name"
-            style="@style/TextAppearance.Material3.HeadlineMedium"
-            android:layout_width="wrap_content"
+        <LinearLayout
+            android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:layout_height="wrap_content"
-            android:textAlignment="viewStart"
-            android:textSize="17sp"
-            app:lineHeight="28dp"
-            tools:text="@string/frame_limit_enable" />
-
-        <com.google.android.material.textview.MaterialTextView
-            android:id="@+id/text_setting_description"
-            style="@style/TextAppearance.Material3.BodySmall"
+            android:orientation="horizontal">
+
+            <LinearLayout
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_marginEnd="24dp"
+                android:gravity="center_vertical"
+                android:orientation="vertical"
+                android:layout_weight="1">
+
+                <com.google.android.material.textview.MaterialTextView
+                    android:id="@+id/text_setting_name"
+                    style="@style/TextAppearance.Material3.HeadlineMedium"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textAlignment="viewStart"
+                    android:textSize="17sp"
+                    app:lineHeight="28dp"
+                    tools:text="@string/frame_limit_enable" />
+
+                <com.google.android.material.textview.MaterialTextView
+                    android:id="@+id/text_setting_description"
+                    style="@style/TextAppearance.Material3.BodySmall"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_small"
+                    android:textAlignment="viewStart"
+                    tools:text="@string/frame_limit_enable_description" />
+
+            </LinearLayout>
+
+            <com.google.android.material.materialswitch.MaterialSwitch
+                android:id="@+id/switch_widget"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center_vertical"/>
+
+        </LinearLayout>
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/button_clear"
+            style="@style/Widget.Material3.Button.TonalButton"
             android:layout_width="wrap_content"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_height="wrap_content"
-            android:layout_marginTop="@dimen/spacing_small"
-            android:textAlignment="viewStart"
-            tools:text="@string/frame_limit_enable_description" />
+            android:layout_marginTop="16dp"
+            android:text="@string/clear"
+            android:visibility="gone"
+            tools:visibility="visible" />
 
 
     </LinearLayout>
     </LinearLayout>
 
 

+ 5 - 0
src/android/app/src/main/res/menu/menu_in_game.xml

@@ -11,6 +11,11 @@
         android:icon="@drawable/ic_settings"
         android:icon="@drawable/ic_settings"
         android:title="@string/preferences_settings" />
         android:title="@string/preferences_settings" />
 
 
+    <item
+        android:id="@+id/menu_settings_per_game"
+        android:icon="@drawable/ic_settings_outline"
+        android:title="@string/per_game_settings" />
+
     <item
     <item
         android:id="@+id/menu_overlay_controls"
         android:id="@+id/menu_overlay_controls"
         android:icon="@drawable/ic_controller"
         android:icon="@drawable/ic_controller"

+ 4 - 0
src/android/app/src/main/res/navigation/emulation_navigation.xml

@@ -15,6 +15,10 @@
             app:argType="org.yuzu.yuzu_emu.model.Game"
             app:argType="org.yuzu.yuzu_emu.model.Game"
             app:nullable="true"
             app:nullable="true"
             android:defaultValue="@null" />
             android:defaultValue="@null" />
+        <argument
+            android:name="custom"
+            app:argType="boolean"
+            android:defaultValue="false" />
     </fragment>
     </fragment>
 
 
     <activity
     <activity

+ 4 - 0
src/android/app/src/main/res/navigation/home_navigation.xml

@@ -77,6 +77,10 @@
             app:argType="org.yuzu.yuzu_emu.model.Game"
             app:argType="org.yuzu.yuzu_emu.model.Game"
             app:nullable="true"
             app:nullable="true"
             android:defaultValue="@null" />
             android:defaultValue="@null" />
+        <argument
+            android:name="custom"
+            app:argType="boolean"
+            android:defaultValue="false" />
     </activity>
     </activity>
 
 
     <action
     <action