android: Add per‑game Custom Settings with explicit overrides

- Save per‑game INIs to config/custom/.ini
  - Always persist explicit choices (even if equal to global); track touched keys
  - JNI overlays per‑game before ApplySettings
  - Show “Use default” per setting; hide when already default
  - Fix presenter to update model on change
This commit is contained in:
Producdevity 2025-09-11 19:54:30 +02:00 committed by OpenSauce
parent e0b8e8440a
commit e1007f1f2e
21 changed files with 365 additions and 38 deletions

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

@ -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,28 @@ 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 saveFile(
fileName: String,
setting: AbstractSetting
@ -189,10 +212,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,8 @@
<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="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)