mirror of
https://github.com/Lime3DS/Lime3DS.git
synced 2025-12-16 12:08:49 +00:00
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:
parent
e0b8e8440a
commit
e1007f1f2e
@ -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)
|
||||
}
|
||||
|
||||
@ -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,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 {
|
||||
|
||||
@ -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,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>
|
||||
|
||||
@ -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…
Reference in New Issue
Block a user