This commit is contained in:
Yassine Gherbi 2025-10-21 17:51:09 -06:00 committed by GitHub
commit 1332e84cf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 552 additions and 38 deletions

View file

@ -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"

View file

@ -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)
}

View 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()
}
}

View file

@ -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 {
)
}
}
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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() {

View file

@ -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 {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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 {

View file

@ -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())

View file

@ -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();
}

View file

@ -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:
/**

View file

@ -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();

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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;

View file

@ -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)