Merge 880375eb7a into 67f6735f02
This commit is contained in:
commit
1332e84cf3
23 changed files with 552 additions and 38 deletions
|
|
@ -78,6 +78,16 @@
|
|||
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="org.citra.citra_emu.features.external.ExternalLaunchActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Citra.Main">
|
||||
<intent-filter>
|
||||
<action android:name="org.citra.citra_emu.LAUNCH_WITH_CUSTOM_CONFIG" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="org.citra.citra_emu.features.cheats.ui.CheatsActivity"
|
||||
android:exported="false"
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ import org.citra.citra_emu.model.Game
|
|||
import org.citra.citra_emu.utils.FileUtil
|
||||
import org.citra.citra_emu.utils.GameIconUtils
|
||||
import org.citra.citra_emu.viewmodel.GamesViewModel
|
||||
import org.citra.citra_emu.features.settings.ui.SettingsActivity
|
||||
import org.citra.citra_emu.features.settings.utils.SettingsFile
|
||||
|
||||
class GameAdapter(private val activity: AppCompatActivity, private val inflater: LayoutInflater, private val openImageLauncher: ActivityResultLauncher<String>?) :
|
||||
ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
|
||||
|
|
@ -441,6 +443,15 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater:
|
|||
bottomSheetDialog.dismiss()
|
||||
}
|
||||
|
||||
bottomSheetView.findViewById<MaterialButton>(R.id.application_settings).setOnClickListener {
|
||||
SettingsActivity.launch(
|
||||
context,
|
||||
SettingsFile.FILE_NAME_CONFIG,
|
||||
String.format("%016X", holder.game.titleId)
|
||||
)
|
||||
bottomSheetDialog.dismiss()
|
||||
}
|
||||
|
||||
bottomSheetView.findViewById<MaterialButton>(R.id.menu_button_open).setOnClickListener {
|
||||
showOpenContextMenu(it, game)
|
||||
}
|
||||
|
|
|
|||
156
src/android/app/src/main/java/org/citra/citra_emu/features/external/ExternalLaunchActivity.kt
vendored
Normal file
156
src/android/app/src/main/java/org/citra/citra_emu/features/external/ExternalLaunchActivity.kt
vendored
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Copyright Citra Emulator Project / Azahar Emulator Project
|
||||
// Licensed under GPLv2 or any later version
|
||||
// Refer to the license.txt file included.
|
||||
|
||||
package org.citra.citra_emu.features.external
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.citra.citra_emu.CitraApplication
|
||||
import org.citra.citra_emu.R
|
||||
import org.citra.citra_emu.activities.EmulationActivity
|
||||
import org.citra.citra_emu.features.settings.utils.SettingsFile
|
||||
import org.citra.citra_emu.model.Game
|
||||
import org.citra.citra_emu.utils.DirectoryInitialization
|
||||
import org.citra.citra_emu.utils.GameHelper
|
||||
import org.citra.citra_emu.utils.Log
|
||||
|
||||
class ExternalLaunchActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Ensure user directory is initialized
|
||||
DirectoryInitialization.start()
|
||||
|
||||
val titleIdStr = intent.getStringExtra(EXTRA_TITLE_ID)
|
||||
val iniText = intent.getStringExtra(EXTRA_CONFIG_INI) ?: ""
|
||||
if (titleIdStr.isNullOrEmpty()) {
|
||||
finishWithResult(success = false)
|
||||
return
|
||||
}
|
||||
|
||||
val titleId = parseTitleId(titleIdStr)
|
||||
if (titleId == null) {
|
||||
finishWithResult(success = false)
|
||||
return
|
||||
}
|
||||
|
||||
// If existing per-game file exists, confirm overwrite
|
||||
val hasExisting = SettingsFile.customExists(String.format("%016X", titleId))
|
||||
if (hasExisting) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.application_settings)
|
||||
.setMessage(R.string.overwrite_custom_settings_prompt)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
proceedWithDriverCheckAndLaunch(titleId, iniText)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> finishWithResult(success = false) }
|
||||
.setOnCancelListener { finishWithResult(success = false) }
|
||||
.show()
|
||||
} else {
|
||||
proceedWithDriverCheckAndLaunch(titleId, iniText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun proceedWithDriverCheckAndLaunch(titleId: Long, iniText: String) {
|
||||
val idHex = String.format("%016X", titleId)
|
||||
val requestedBackend = extractGraphicsApi(iniText)
|
||||
|
||||
if (requestedBackend == GRAPHICS_BACKEND_VULKAN) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.custom_launch_backend_warning_title)
|
||||
.setMessage(R.string.custom_launch_backend_warning_message)
|
||||
.setPositiveButton(R.string.custom_launch_backend_option_use_vulkan) { _, _ ->
|
||||
writeConfigAndLaunch(titleId, iniText, idHex)
|
||||
}
|
||||
.setNegativeButton(R.string.custom_launch_backend_option_use_opengl) { _, _ ->
|
||||
val adjusted = replaceGraphicsApi(iniText, GRAPHICS_BACKEND_OPENGL)
|
||||
Log.info("[ExternalLaunch] Falling back to OpenGL for external launch")
|
||||
writeConfigAndLaunch(titleId, adjusted, idHex)
|
||||
}
|
||||
.setOnCancelListener { finishWithResult(success = false) }
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
writeConfigAndLaunch(titleId, iniText, idHex)
|
||||
}
|
||||
|
||||
private fun writeConfigAndLaunch(titleId: Long, iniText: String, idHex: String) {
|
||||
SettingsFile.saveCustomFileRaw(idHex, iniText)
|
||||
|
||||
val game = findGameByTitleId(titleId)
|
||||
if (game == null) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.custom_launch_missing_game_title)
|
||||
.setMessage(getString(R.string.custom_launch_missing_game_message, idHex))
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finishWithResult(success = false) }
|
||||
.setOnCancelListener { finishWithResult(success = false) }
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val launch = Intent(this, EmulationActivity::class.java)
|
||||
launch.putExtra("game", game)
|
||||
startActivity(launch)
|
||||
finishWithResult(success = true)
|
||||
}
|
||||
|
||||
private fun findGameByTitleId(titleId: Long): Game? {
|
||||
// Try cached games first
|
||||
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
|
||||
val serialized = prefs.getStringSet(GameHelper.KEY_GAMES, emptySet()) ?: emptySet()
|
||||
if (serialized.isNotEmpty()) {
|
||||
val games = serialized.mapNotNull {
|
||||
try { kotlinx.serialization.json.Json.decodeFromString(org.citra.citra_emu.model.Game.serializer(), it) } catch (_: Exception) { null }
|
||||
}
|
||||
games.firstOrNull { it.titleId == titleId }?.let { return it }
|
||||
}
|
||||
// Fallback: rescan library
|
||||
return GameHelper.getGames().firstOrNull { it.titleId == titleId }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_TITLE_ID = "title_id"
|
||||
const val EXTRA_CONFIG_INI = "config_ini"
|
||||
private const val GRAPHICS_BACKEND_OPENGL = 1
|
||||
private const val GRAPHICS_BACKEND_VULKAN = 2
|
||||
}
|
||||
|
||||
private fun parseTitleId(raw: String?): Long? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val trimmed = raw.trim()
|
||||
val withoutPrefix = if (trimmed.startsWith("0x", true)) trimmed.substring(2) else trimmed
|
||||
|
||||
// Prefer hexadecimal interpretation – Title IDs are traditionally provided in hex.
|
||||
val hexValue = withoutPrefix.toLongOrNull(16)
|
||||
if (hexValue != null) return hexValue
|
||||
|
||||
return withoutPrefix.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun extractGraphicsApi(config: String): Int? {
|
||||
val regex = Regex(
|
||||
"^\\s*graphics_api\\s*=\\s*(\\d+)\\s*$",
|
||||
setOf(RegexOption.IGNORE_CASE, RegexOption.MULTILINE)
|
||||
)
|
||||
val match = regex.find(config) ?: return null
|
||||
return match.groupValues.getOrNull(1)?.trim()?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun replaceGraphicsApi(config: String, backend: Int): String {
|
||||
val regex = Regex(
|
||||
"^\\s*graphics_api\\s*=\\s*(\\d+)\\s*$",
|
||||
setOf(RegexOption.IGNORE_CASE, RegexOption.MULTILINE)
|
||||
)
|
||||
return regex.replace(config) { "graphics_api = $backend" }
|
||||
}
|
||||
|
||||
private fun finishWithResult(success: Boolean) {
|
||||
setResult(if (success) RESULT_OK else RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ class Settings {
|
|||
private var gameId: String? = null
|
||||
|
||||
var isLoaded = false
|
||||
private val touchedKeys: MutableSet<String> = mutableSetOf()
|
||||
|
||||
/**
|
||||
* A HashMap<String></String>, SettingSection> that constructs a new SettingSection instead of returning null
|
||||
|
|
@ -42,6 +43,7 @@ class Settings {
|
|||
|
||||
fun loadSettings(view: SettingsActivityView? = null) {
|
||||
sections = SettingsSectionMap()
|
||||
touchedKeys.clear()
|
||||
loadCitraSettings(view)
|
||||
if (!TextUtils.isEmpty(gameId)) {
|
||||
loadCustomGameSettings(gameId!!, view)
|
||||
|
|
@ -86,11 +88,92 @@ class Settings {
|
|||
val iniSections = TreeMap<String, SettingSection?>()
|
||||
for (section in sectionNames) {
|
||||
iniSections[section] = sections[section]
|
||||
}
|
||||
SettingsFile.saveFile(fileName, iniSections, view)
|
||||
}
|
||||
SettingsFile.saveFile(fileName, iniSections, view)
|
||||
}
|
||||
} else {
|
||||
// TODO: Implement per game settings
|
||||
// Save per-game settings to config/custom/<gameId>.ini.
|
||||
// Compare current (merged) values to global config.ini and include explicit choices.
|
||||
val globalSections = SettingsFile.readFile(SettingsFile.FILE_NAME_CONFIG, view)
|
||||
|
||||
val overrides = TreeMap<String, SettingSection?>()
|
||||
val priorOverrides = SettingsFile.readCustomGameSettings(gameId!!, view)
|
||||
for ((sectionName, effectiveSection) in sections) {
|
||||
if (effectiveSection == null) continue
|
||||
val globalSection = globalSections[sectionName]
|
||||
val priorSection = priorOverrides[sectionName]
|
||||
|
||||
val overrideSection = SettingSection(sectionName)
|
||||
for ((key, effSetting) in effectiveSection.settings) {
|
||||
if (effSetting == null) continue
|
||||
|
||||
val globalSetting = globalSection?.getSetting(key)
|
||||
val hadPrior = priorSection?.getSetting(key) != null
|
||||
val wasTouched = touchedKeys.contains("$sectionName::$key")
|
||||
|
||||
// Include key when one of the following is true:
|
||||
// - value differs from the compiled default (explicit choice),
|
||||
// - key existed previously in the per-game file (preserve intent),
|
||||
// - user touched this setting in this session (explicit choice).
|
||||
if (!isDefaultValue(effSetting) || hadPrior || wasTouched) {
|
||||
val toWrite: AbstractSetting = if (isDefaultValue(effSetting))
|
||||
SimpleStringSetting(key, sectionName, "") else effSetting
|
||||
overrideSection.putSetting(toWrite)
|
||||
}
|
||||
}
|
||||
|
||||
if (!overrideSection.settings.isEmpty()) {
|
||||
overrides[sectionName] = overrideSection
|
||||
}
|
||||
}
|
||||
|
||||
view.showToastMessage(
|
||||
CitraApplication.appContext.getString(R.string.ini_saved),
|
||||
false
|
||||
)
|
||||
SettingsFile.saveCustomFile(gameId!!, overrides, view)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDefaultValue(setting: AbstractSetting): Boolean = when (setting) {
|
||||
is AbstractBooleanSetting -> setting.boolean == setting.defaultValue
|
||||
is AbstractIntSetting -> setting.int == setting.defaultValue
|
||||
is ScaledFloatSetting -> setting.float == setting.defaultValue * setting.scale
|
||||
is FloatSetting -> setting.float == setting.defaultValue
|
||||
is AbstractShortSetting -> setting.short == setting.defaultValue
|
||||
is AbstractStringSetting -> setting.string == setting.defaultValue
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun areSettingsEqual(a: AbstractSetting, b: AbstractSetting?): Boolean {
|
||||
if (b == null) {
|
||||
// Global missing means it uses compiled default; equal if a is default.
|
||||
return isDefaultValue(a)
|
||||
}
|
||||
return when {
|
||||
a is AbstractBooleanSetting && b is AbstractBooleanSetting -> a.boolean == b.boolean
|
||||
a is AbstractIntSetting && b is AbstractIntSetting -> a.int == b.int
|
||||
a is ScaledFloatSetting && b is ScaledFloatSetting -> a.float == b.float
|
||||
a is FloatSetting && b is FloatSetting -> a.float == b.float
|
||||
a is AbstractShortSetting && b is AbstractShortSetting -> a.short == b.short
|
||||
a is AbstractStringSetting && b is AbstractStringSetting -> a.string == b.string
|
||||
else -> a.valueAsString == b.valueAsString
|
||||
}
|
||||
}
|
||||
|
||||
private data class SimpleStringSetting(
|
||||
override val key: String?,
|
||||
override val section: String?,
|
||||
private val value: String
|
||||
) : AbstractSetting {
|
||||
override val isRuntimeEditable: Boolean get() = true
|
||||
override val valueAsString: String get() = value
|
||||
override val defaultValue: Any get() = ""
|
||||
}
|
||||
|
||||
fun markTouched(setting: AbstractSetting) {
|
||||
if (setting.section != null && setting.key != null) {
|
||||
touchedKeys.add("${setting.section}::${setting.key}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,4 +328,4 @@ class Settings {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ class SettingsActivityPresenter(private val activityView: SettingsActivityView)
|
|||
fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) {
|
||||
this.menuTag = menuTag
|
||||
this.gameId = gameId
|
||||
// Force reload of settings for each entry to avoid leaking values between
|
||||
// global and per-game contexts in the shared Settings instance.
|
||||
settings.isLoaded = false
|
||||
if (savedInstanceState != null) {
|
||||
shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class SettingsAdapter(
|
|||
public val context: Context
|
||||
) : RecyclerView.Adapter<SettingViewHolder?>(), DialogInterface.OnClickListener {
|
||||
private var settings: ArrayList<SettingsItem>? = null
|
||||
var isPerGame: Boolean = false
|
||||
private var clickedItem: SettingsItem? = null
|
||||
private var clickedPosition: Int
|
||||
private var dialog: AlertDialog? = null
|
||||
|
|
@ -531,23 +532,7 @@ class SettingsAdapter(
|
|||
MaterialAlertDialogBuilder(context)
|
||||
.setMessage(R.string.reset_setting_confirmation)
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
|
||||
when (setting) {
|
||||
is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean
|
||||
is AbstractFloatSetting -> {
|
||||
if (setting is ScaledFloatSetting) {
|
||||
setting.float = setting.defaultValue * setting.scale
|
||||
} else {
|
||||
setting.float = setting.defaultValue as Float
|
||||
}
|
||||
}
|
||||
|
||||
is AbstractIntSetting -> setting.int = setting.defaultValue as Int
|
||||
is AbstractStringSetting -> setting.string = setting.defaultValue as String
|
||||
is AbstractShortSetting -> setting.short = setting.defaultValue as Short
|
||||
}
|
||||
notifyItemChanged(position)
|
||||
fragmentView.onSettingChanged()
|
||||
fragmentView.loadSettingsList()
|
||||
resetSettingToDefault(setting, position)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
|
@ -555,6 +540,35 @@ class SettingsAdapter(
|
|||
return true
|
||||
}
|
||||
|
||||
fun resetSettingToDefault(setting: AbstractSetting, position: Int) {
|
||||
when (setting) {
|
||||
is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean
|
||||
is AbstractFloatSetting -> {
|
||||
if (setting is ScaledFloatSetting) {
|
||||
setting.float = setting.defaultValue * setting.scale
|
||||
} else {
|
||||
setting.float = setting.defaultValue as Float
|
||||
}
|
||||
}
|
||||
is AbstractIntSetting -> setting.int = setting.defaultValue as Int
|
||||
is AbstractStringSetting -> setting.string = setting.defaultValue as String
|
||||
is AbstractShortSetting -> setting.short = setting.defaultValue as Short
|
||||
}
|
||||
notifyItemChanged(position)
|
||||
fragmentView.onSettingChanged()
|
||||
fragmentView.loadSettingsList()
|
||||
}
|
||||
|
||||
fun isAtCompiledDefault(s: AbstractSetting): Boolean = when (s) {
|
||||
is AbstractBooleanSetting -> s.boolean == s.defaultValue
|
||||
is AbstractIntSetting -> s.int == s.defaultValue
|
||||
is ScaledFloatSetting -> s.float == s.defaultValue * s.scale
|
||||
is AbstractFloatSetting -> s.float == s.defaultValue
|
||||
is AbstractShortSetting -> s.short == s.defaultValue
|
||||
is AbstractStringSetting -> s.string == s.defaultValue
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun onClickDisabledSetting(isRuntimeDisabled: Boolean) {
|
||||
val titleId = if (isRuntimeDisabled)
|
||||
R.string.setting_not_editable
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
|
|||
fun onViewCreated(settingsAdapter: SettingsAdapter) {
|
||||
this.settingsAdapter = settingsAdapter
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
|
||||
settingsAdapter.isPerGame = !TextUtils.isEmpty(gameId)
|
||||
loadSettingsList()
|
||||
}
|
||||
|
||||
|
|
@ -74,9 +75,9 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
|
|||
}
|
||||
|
||||
val section = settings.getSection(setting.section!!)!!
|
||||
if (section.getSetting(setting.key!!) == null) {
|
||||
section.putSetting(setting)
|
||||
}
|
||||
// Update setting and mark as touched so changes persist and save explicitly.
|
||||
section.putSetting(setting)
|
||||
settings.markTouched(setting)
|
||||
}
|
||||
|
||||
fun loadSettingsList() {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,18 @@ class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: Setti
|
|||
binding.textSettingDescription.alpha = 0.5f
|
||||
binding.textSettingValue.alpha = 0.5f
|
||||
}
|
||||
|
||||
// Show "Use default" button in Custom Settings if applicable.
|
||||
val adapterIsPerGame = adapter.isPerGame
|
||||
val showDefault = adapterIsPerGame && setting.setting != null && !adapter.isAtCompiledDefault(setting.setting!!)
|
||||
binding.buttonUseDefault.visibility = if (showDefault) View.VISIBLE else View.GONE
|
||||
if (showDefault) {
|
||||
binding.buttonUseDefault.setOnClickListener {
|
||||
if (setting.setting != null) {
|
||||
adapter.resetSettingToDefault(setting.setting!!, bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTextSetting(): String {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,18 @@ class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAda
|
|||
binding.textSettingDescription.alpha = 0.5f
|
||||
binding.textSettingValue.alpha = 0.5f
|
||||
}
|
||||
|
||||
// Show "Use default" button in Custom Settings if applicable.
|
||||
val adapterIsPerGame = adapter.isPerGame
|
||||
val showDefault = adapterIsPerGame && setting.setting != null && !adapter.isAtCompiledDefault(setting.setting!!)
|
||||
binding.buttonUseDefault.visibility = if (showDefault) View.VISIBLE else View.GONE
|
||||
if (showDefault) {
|
||||
binding.buttonUseDefault.setOnClickListener {
|
||||
if (setting.setting != null) {
|
||||
adapter.resetSettingToDefault(setting.setting!!, bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: Settin
|
|||
binding.textSettingDescription.alpha = 0.5f
|
||||
binding.textSettingValue.alpha = 0.5f
|
||||
}
|
||||
|
||||
// Show "Use default" button in Custom Settings if applicable.
|
||||
val adapterIsPerGame = adapter.isPerGame
|
||||
val showDefault = adapterIsPerGame && setting.setting != null && !adapter.isAtCompiledDefault(setting.setting!!)
|
||||
binding.buttonUseDefault.visibility = if (showDefault) View.VISIBLE else View.GONE
|
||||
if (showDefault) {
|
||||
binding.buttonUseDefault.setOnClickListener {
|
||||
if (setting.setting != null) {
|
||||
adapter.resetSettingToDefault(setting.setting!!, bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,18 @@ class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter
|
|||
val textAlpha = if (setting.isActive) 1f else 0.5f
|
||||
binding.textSettingName.alpha = textAlpha
|
||||
binding.textSettingDescription.alpha = textAlpha
|
||||
|
||||
// Show "Use default" button in Custom Settings if applicable.
|
||||
val adapterIsPerGame = adapter.isPerGame
|
||||
val showDefault = adapterIsPerGame && setting.setting != null && !adapter.isAtCompiledDefault(setting.setting!!)
|
||||
binding.buttonUseDefault.visibility = if (showDefault) View.VISIBLE else View.GONE
|
||||
if (showDefault) {
|
||||
binding.buttonUseDefault.setOnClickListener {
|
||||
if (setting.setting != null) {
|
||||
adapter.resetSettingToDefault(setting.setting!!, bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) {
|
||||
|
|
|
|||
|
|
@ -107,7 +107,8 @@ object SettingsFile {
|
|||
gameId: String,
|
||||
view: SettingsActivityView?
|
||||
): HashMap<String, SettingSection?> {
|
||||
return readFile(getCustomGameSettingsFile(gameId), true, view)
|
||||
val file = findCustomGameSettingsFile(gameId) ?: return SettingsSectionMap()
|
||||
return readFile(file, true, view)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -147,6 +148,41 @@ object SettingsFile {
|
|||
}
|
||||
}
|
||||
|
||||
fun saveCustomFile(
|
||||
gameId: String,
|
||||
sections: TreeMap<String, SettingSection?>,
|
||||
view: SettingsActivityView
|
||||
) {
|
||||
val ini = getOrCreateCustomGameSettingsFile(gameId)
|
||||
try {
|
||||
val context: Context = CitraApplication.appContext
|
||||
val outputStream = context.contentResolver.openOutputStream(ini.uri, "wt")
|
||||
val parser = Wini()
|
||||
for ((_, section) in sections) {
|
||||
if (section != null) writeSection(parser, section)
|
||||
}
|
||||
parser.store(outputStream)
|
||||
outputStream!!.flush()
|
||||
outputStream.close()
|
||||
} catch (e: Exception) {
|
||||
Log.error("[SettingsFile] Error saving custom file: config/custom/$gameId.ini: ${e.message}")
|
||||
view.onSettingsFileNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCustomFileRaw(gameId: String, contents: String) {
|
||||
val ini = getOrCreateCustomGameSettingsFile(gameId)
|
||||
val context: Context = CitraApplication.appContext
|
||||
context.contentResolver.openOutputStream(ini.uri, "wt").use { out ->
|
||||
out?.write(contents.toByteArray())
|
||||
out?.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fun customExists(gameId: String): Boolean {
|
||||
return findCustomGameSettingsFile(gameId) != null
|
||||
}
|
||||
|
||||
fun saveFile(
|
||||
fileName: String,
|
||||
setting: AbstractSetting
|
||||
|
|
@ -189,10 +225,26 @@ object SettingsFile {
|
|||
return configDirectory!!.findFile("$fileName.ini")!!
|
||||
}
|
||||
|
||||
private fun getCustomGameSettingsFile(gameId: String): DocumentFile {
|
||||
private fun findCustomGameSettingsFile(gameId: String): DocumentFile? {
|
||||
val root = DocumentFile.fromTreeUri(CitraApplication.appContext, Uri.parse(userDirectory))
|
||||
val configDirectory = root!!.findFile("GameSettings")
|
||||
return configDirectory!!.findFile("$gameId.ini")!!
|
||||
val configDir = root?.findFile("config") ?: return null
|
||||
val customDir = configDir.findFile("custom") ?: return null
|
||||
return customDir.findFile("$gameId.ini")
|
||||
}
|
||||
|
||||
private fun getOrCreateCustomGameSettingsFile(gameId: String): DocumentFile {
|
||||
val root = DocumentFile.fromTreeUri(CitraApplication.appContext, Uri.parse(userDirectory))!!
|
||||
val configDir = root.findFile("config") ?: root.createDirectory("config")
|
||||
var customDir = configDir?.findFile("custom")
|
||||
if (customDir == null || !customDir.isDirectory) {
|
||||
customDir = configDir?.createDirectory("custom")
|
||||
}
|
||||
var file = customDir!!.findFile("$gameId.ini")
|
||||
if (file == null) {
|
||||
// Use generic MIME to avoid providers appending ".txt" to the name
|
||||
file = customDir.createFile("*/*", "$gameId.ini")
|
||||
}
|
||||
return file!!
|
||||
}
|
||||
|
||||
private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection {
|
||||
|
|
|
|||
|
|
@ -359,6 +359,27 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
|
|||
true
|
||||
}
|
||||
|
||||
R.id.menu_application_settings -> {
|
||||
val titleId = NativeLibrary.getRunningTitleId()
|
||||
if (titleId != 0L) {
|
||||
val gameId = java.lang.String.format("%016X", titleId)
|
||||
SettingsActivity.launch(
|
||||
requireContext(),
|
||||
SettingsFile.FILE_NAME_CONFIG,
|
||||
gameId
|
||||
)
|
||||
} else {
|
||||
// Fallback: open global settings if title id unknown
|
||||
SettingsActivity.launch(
|
||||
requireContext(),
|
||||
SettingsFile.FILE_NAME_CONFIG,
|
||||
""
|
||||
)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
R.id.menu_exit -> {
|
||||
emulationState.pause()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
|
|
|
|||
|
|
@ -77,7 +77,12 @@ static const std::array<int, Settings::NativeAnalog::NumAnalogs> default_analogs
|
|||
|
||||
template <>
|
||||
void Config::ReadSetting(const std::string& group, Settings::Setting<std::string>& setting) {
|
||||
std::string setting_value = sdl2_config->Get(group, setting.GetLabel(), setting.GetDefault());
|
||||
std::string setting_value = setting.GetDefault();
|
||||
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
|
||||
setting_value = per_game_config->Get(group, setting.GetLabel(), setting_value);
|
||||
} else if (sdl2_config) {
|
||||
setting_value = sdl2_config->Get(group, setting.GetLabel(), setting_value);
|
||||
}
|
||||
if (setting_value.empty()) {
|
||||
setting_value = setting.GetDefault();
|
||||
}
|
||||
|
|
@ -86,16 +91,33 @@ void Config::ReadSetting(const std::string& group, Settings::Setting<std::string
|
|||
|
||||
template <>
|
||||
void Config::ReadSetting(const std::string& group, Settings::Setting<bool>& setting) {
|
||||
setting = sdl2_config->GetBoolean(group, setting.GetLabel(), setting.GetDefault());
|
||||
bool value = setting.GetDefault();
|
||||
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
|
||||
value = per_game_config->GetBoolean(group, setting.GetLabel(), value);
|
||||
} else if (sdl2_config) {
|
||||
value = sdl2_config->GetBoolean(group, setting.GetLabel(), value);
|
||||
}
|
||||
setting = value;
|
||||
}
|
||||
|
||||
template <typename Type, bool ranged>
|
||||
void Config::ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting) {
|
||||
if constexpr (std::is_floating_point_v<Type>) {
|
||||
setting = sdl2_config->GetReal(group, setting.GetLabel(), setting.GetDefault());
|
||||
double value = static_cast<double>(setting.GetDefault());
|
||||
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
|
||||
value = per_game_config->GetReal(group, setting.GetLabel(), value);
|
||||
} else if (sdl2_config) {
|
||||
value = sdl2_config->GetReal(group, setting.GetLabel(), value);
|
||||
}
|
||||
setting = static_cast<Type>(value);
|
||||
} else {
|
||||
setting = static_cast<Type>(sdl2_config->GetInteger(
|
||||
group, setting.GetLabel(), static_cast<long>(setting.GetDefault())));
|
||||
long value = static_cast<long>(setting.GetDefault());
|
||||
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
|
||||
value = per_game_config->GetInteger(group, setting.GetLabel(), value);
|
||||
} else if (sdl2_config) {
|
||||
value = sdl2_config->GetInteger(group, setting.GetLabel(), value);
|
||||
}
|
||||
setting = static_cast<Type>(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,3 +342,37 @@ void Config::Reload() {
|
|||
LoadINI(DefaultINI::sdl2_config_file);
|
||||
ReadValues();
|
||||
}
|
||||
|
||||
void Config::LoadPerGameConfig(u64 title_id, const std::string& fallback_name) {
|
||||
// Determine file name
|
||||
std::string name;
|
||||
if (title_id != 0) {
|
||||
std::ostringstream ss;
|
||||
ss << std::uppercase << std::hex << std::setw(16) << std::setfill('0') << title_id;
|
||||
name = ss.str();
|
||||
} else {
|
||||
name = fallback_name;
|
||||
}
|
||||
if (name.empty()) {
|
||||
per_game_config.reset();
|
||||
per_game_config_loc.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto base = FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir);
|
||||
per_game_config_loc = base + "custom/" + name + ".ini";
|
||||
|
||||
std::string ini_buffer;
|
||||
FileUtil::ReadFileToString(true, per_game_config_loc, ini_buffer);
|
||||
if (!ini_buffer.empty()) {
|
||||
per_game_config = std::make_unique<INIReader>(ini_buffer.c_str(), ini_buffer.size());
|
||||
if (per_game_config->ParseError() < 0) {
|
||||
per_game_config.reset();
|
||||
}
|
||||
} else {
|
||||
per_game_config.reset();
|
||||
}
|
||||
|
||||
// Re-apply values so that per-game overrides (if any) take effect immediately.
|
||||
ReadValues();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ class Config {
|
|||
private:
|
||||
std::unique_ptr<INIReader> sdl2_config;
|
||||
std::string sdl2_config_loc;
|
||||
std::unique_ptr<INIReader> per_game_config;
|
||||
std::string per_game_config_loc;
|
||||
|
||||
bool LoadINI(const std::string& default_contents = "", bool retry = true);
|
||||
void ReadValues();
|
||||
|
|
@ -23,6 +25,8 @@ public:
|
|||
~Config();
|
||||
|
||||
void Reload();
|
||||
// Load a per-game config overlay by title id or fallback name. Does not create files.
|
||||
void LoadPerGameConfig(u64 title_id, const std::string& fallback_name = "");
|
||||
|
||||
private:
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ static Core::System::ResultStatus RunCitra(const std::string& filepath) {
|
|||
}
|
||||
|
||||
// Forces a config reload on game boot, if the user changed settings in the UI
|
||||
Config{};
|
||||
// Replace with game-specific settings
|
||||
Config global_config{};
|
||||
// Load game-specific settings overlay if available
|
||||
u64 program_id{};
|
||||
FileUtil::SetCurrentRomPath(filepath);
|
||||
auto app_loader = Loader::GetLoader(filepath);
|
||||
|
|
@ -214,6 +214,10 @@ static Core::System::ResultStatus RunCitra(const std::string& filepath) {
|
|||
app_loader->ReadProgramId(program_id);
|
||||
system.RegisterAppLoaderEarly(app_loader);
|
||||
}
|
||||
// Use filename as fallback if title id is zero (e.g., homebrew)
|
||||
const std::string fallback_name =
|
||||
program_id == 0 ? std::string(FileUtil::GetFilename(filepath)) : std::string{};
|
||||
global_config.LoadPerGameConfig(program_id, fallback_name);
|
||||
system.ApplySettings();
|
||||
Settings::LogSettings();
|
||||
|
||||
|
|
@ -726,13 +730,18 @@ void Java_org_citra_citra_1emu_NativeLibrary_logUserDirectory(JNIEnv* env,
|
|||
|
||||
void Java_org_citra_citra_1emu_NativeLibrary_reloadSettings([[maybe_unused]] JNIEnv* env,
|
||||
[[maybe_unused]] jobject obj) {
|
||||
Config{};
|
||||
Config cfg{};
|
||||
Core::System& system{Core::System::GetInstance()};
|
||||
|
||||
// Replace with game-specific settings
|
||||
// Load game-specific settings overlay (if a game is running)
|
||||
if (system.IsPoweredOn()) {
|
||||
u64 program_id{};
|
||||
system.GetAppLoader().ReadProgramId(program_id);
|
||||
// Use the registered ROM path (if any) to derive a fallback name
|
||||
const std::string current_rom_path = FileUtil::GetCurrentRomPath();
|
||||
const std::string fallback_name =
|
||||
program_id == 0 ? std::string(FileUtil::GetFilename(current_rom_path)) : std::string{};
|
||||
cfg.LoadPerGameConfig(program_id, fallback_name);
|
||||
}
|
||||
|
||||
system.ApplySettings();
|
||||
|
|
|
|||
|
|
@ -179,6 +179,14 @@
|
|||
android:contentDescription="@string/cheats"
|
||||
android:text="@string/cheats" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/application_settings"
|
||||
style="@style/Widget.Material3.Button.TonalButton.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/application_settings"
|
||||
android:text="@string/application_settings" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,15 @@
|
|||
android:textSize="13sp"
|
||||
tools:text="1x" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_use_default"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/use_default"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@
|
|||
android:textAlignment="viewStart"
|
||||
tools:text="@string/frame_limit_enable_description" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_use_default"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/use_default"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@
|
|||
android:icon="@drawable/ic_settings"
|
||||
android:title="@string/preferences_settings" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_application_settings"
|
||||
android:icon="@drawable/ic_settings"
|
||||
android:title="@string/application_settings" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_exit"
|
||||
android:icon="@drawable/ic_exit"
|
||||
|
|
|
|||
|
|
@ -494,6 +494,16 @@
|
|||
<string name="menu_emulation_amiibo">Amiibo</string>
|
||||
<string name="menu_emulation_amiibo_load">Load</string>
|
||||
<string name="menu_emulation_amiibo_remove">Remove</string>
|
||||
<string name="application_settings">Custom Settings</string>
|
||||
<string name="use_default">Use default</string>
|
||||
<string name="overwrite_custom_settings_prompt">Custom settings for this game already exist. Overwrite them and proceed?</string>
|
||||
<string name="custom_launch_missing_game_title">Game not found</string>
|
||||
<string name="custom_launch_missing_game_message">This title isn\'t in your Azahar library (ID: %1$s). Add it to the library and try again.</string>
|
||||
<string name="custom_launch_backend_warning_title">Vulkan backend requested</string>
|
||||
<string name="custom_launch_backend_warning_message">This configuration asks Azahar to use the Vulkan renderer. Some devices crash when launching games externally with Vulkan. Keep Vulkan, or switch this launch to OpenGL for safety?</string>
|
||||
<string name="custom_launch_backend_option_use_vulkan">Use Vulkan</string>
|
||||
<string name="custom_launch_backend_option_use_opengl">Use OpenGL</string>
|
||||
<string name="required_driver_missing">Missing required driver: %1$s. Please install it and try again.</string>
|
||||
<string name="select_amiibo">Select Amiibo File</string>
|
||||
<string name="amiibo_load_error">Error Loading Amiibo</string>
|
||||
<string name="amiibo_load_error_message">While loading the specified Amiibo file, an error occurred. Please check that the file is correct.</string>
|
||||
|
|
|
|||
|
|
@ -884,6 +884,10 @@ void SetCurrentRomPath(const std::string& path) {
|
|||
g_currentRomPath = path;
|
||||
}
|
||||
|
||||
std::string GetCurrentRomPath() {
|
||||
return g_currentRomPath;
|
||||
}
|
||||
|
||||
bool StringReplace(std::string& haystack, const std::string& a, const std::string& b, bool swap) {
|
||||
const auto& needle = swap ? b : a;
|
||||
const auto& replacement = swap ? a : b;
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ bool SetCurrentDir(const std::string& directory);
|
|||
void SetUserPath(const std::string& path = "");
|
||||
|
||||
void SetCurrentRomPath(const std::string& path);
|
||||
[[nodiscard]] std::string GetCurrentRomPath();
|
||||
|
||||
// Returns a pointer to a string with a Citra data dir in the user's home
|
||||
// directory. To be used in "multi-user" mode (that is, installed).
|
||||
|
|
@ -547,4 +548,4 @@ void OpenFStream(T& fstream, const std::string& filename, std::ios_base::openmod
|
|||
}
|
||||
|
||||
BOOST_CLASS_EXPORT_KEY(FileUtil::IOFile)
|
||||
BOOST_CLASS_EXPORT_KEY(FileUtil::CryptoIOFile)
|
||||
BOOST_CLASS_EXPORT_KEY(FileUtil::CryptoIOFile)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue