added per game settings

This commit is contained in:
izzy2lost
2026-03-24 14:04:40 -04:00
parent bc3aae545d
commit dca8a354df
11 changed files with 1006 additions and 37 deletions
+5
View File
@@ -61,5 +61,10 @@
android:screenOrientation="fullSensor"
android:exported="false" />
<activity
android:name=".PerGameSettingsActivity"
android:screenOrientation="fullSensor"
android:exported="false" />
</application>
</manifest>
+101 -15
View File
@@ -39,6 +39,7 @@ extern "C" AddfdInfo* monitor_fdset_add_fd(int fd, bool has_fdset_id,
namespace {
constexpr const char* kLogTag = "xemu-android";
constexpr const char* kPrefsName = "x1box_prefs";
constexpr const char* kRuntimeOverridePrefPrefix = "runtime_override_";
constexpr const char* kDebugLogPrefKey = "setting_debug_logs_enabled";
constexpr const char* kHrtfPrefKey = "setting_hrtf";
constexpr const char* kHrtfDefaultOffMigrationPrefKey =
@@ -56,6 +57,12 @@ static jobject GetActivity(JNIEnv* env);
static bool HasException(JNIEnv* env, const char* context);
static std::string GetFilesDirPath(JNIEnv* env, jobject activity);
static std::string GetPrefString(JNIEnv* env, jobject activity, const char* key);
static std::string GetEffectivePrefString(JNIEnv* env, jobject activity,
const char* key);
static bool GetEffectivePrefBool(JNIEnv* env, jobject activity, const char* key,
bool defValue);
static int GetEffectivePrefInt(JNIEnv* env, jobject activity, const char* key,
int defValue);
static void ConfigureNativeDebugLogging(JNIEnv* env, jobject activity);
static void ApplyHrtfDefaultOffMigration(JNIEnv* env, jobject activity);
static bool NativeDebugLoggingEnabled();
@@ -348,7 +355,8 @@ static std::string ResolveAndroidOrientationHint(JNIEnv* env, jobject activity)
return kDefaultOrientationHint;
}
std::string value = ToLowerAscii(GetPrefString(env, activity, "setting_game_orientation"));
std::string value =
ToLowerAscii(GetEffectivePrefString(env, activity, "setting_game_orientation"));
if (value == "landscape") {
return "LandscapeLeft";
}
@@ -729,6 +737,74 @@ static int GetPrefInt(JNIEnv* env, jobject activity, const char* key, int defVal
return out;
}
static std::string BuildRuntimeOverrideKey(const char* key) {
if (!key || key[0] == '\0') {
return {};
}
return std::string(kRuntimeOverridePrefPrefix) + key;
}
static bool ParseOverrideBool(const std::string& value, bool* out) {
if (!out) {
return false;
}
const std::string normalized = ToLowerAscii(value);
if (normalized == "true" || normalized == "1") {
*out = true;
return true;
}
if (normalized == "false" || normalized == "0") {
*out = false;
return true;
}
return false;
}
static std::string GetEffectivePrefString(JNIEnv* env, jobject activity,
const char* key) {
const std::string runtimeKey = BuildRuntimeOverrideKey(key);
if (!runtimeKey.empty()) {
const std::string overrideValue =
GetPrefString(env, activity, runtimeKey.c_str());
if (!overrideValue.empty()) {
return overrideValue;
}
}
return GetPrefString(env, activity, key);
}
static bool GetEffectivePrefBool(JNIEnv* env, jobject activity, const char* key,
bool defValue) {
const std::string runtimeKey = BuildRuntimeOverrideKey(key);
if (!runtimeKey.empty()) {
const std::string overrideValue =
GetPrefString(env, activity, runtimeKey.c_str());
bool parsed = false;
if (ParseOverrideBool(overrideValue, &parsed)) {
return parsed;
}
}
return GetPrefBool(env, activity, key, defValue);
}
static int GetEffectivePrefInt(JNIEnv* env, jobject activity, const char* key,
int defValue) {
const std::string runtimeKey = BuildRuntimeOverrideKey(key);
if (!runtimeKey.empty()) {
const std::string overrideValue =
GetPrefString(env, activity, runtimeKey.c_str());
if (!overrideValue.empty()) {
char* end = nullptr;
const long parsed = std::strtol(overrideValue.c_str(), &end, 10);
if (end != overrideValue.c_str() && end && *end == '\0' &&
parsed >= INT_MIN && parsed <= INT_MAX) {
return static_cast<int>(parsed);
}
}
}
return GetPrefInt(env, activity, key, defValue);
}
static void ConfigureNativeDebugLogging(JNIEnv* env, jobject activity) {
g_native_debug_logging_enabled.store(
GetPrefBool(env, activity, kDebugLogPrefKey, false));
@@ -1273,29 +1349,36 @@ static SetupFiles SyncSetupFiles() {
}
EmulatorSettings emuSettings;
emuSettings.surface_scale = GetPrefInt(env, activity, "setting_surface_scale", 1);
emuSettings.surface_scale =
GetEffectivePrefInt(env, activity, "setting_surface_scale", 1);
emuSettings.system_memory_mib =
GetPrefInt(env, activity, "setting_system_memory_mib", 64);
GetEffectivePrefInt(env, activity, "setting_system_memory_mib", 64);
if (emuSettings.system_memory_mib != 64 &&
emuSettings.system_memory_mib != 128) {
emuSettings.system_memory_mib = 64;
}
emuSettings.use_dsp = GetPrefBool(env, activity, "setting_use_dsp", false);
emuSettings.hrtf = GetPrefBool(env, activity, kHrtfPrefKey, false);
emuSettings.cache_shaders = GetPrefBool(env, activity, "setting_cache_shaders", true);
emuSettings.hard_fpu = GetPrefBool(env, activity, "setting_hard_fpu", true);
emuSettings.use_dsp =
GetEffectivePrefBool(env, activity, "setting_use_dsp", false);
emuSettings.hrtf =
GetEffectivePrefBool(env, activity, kHrtfPrefKey, false);
emuSettings.cache_shaders =
GetEffectivePrefBool(env, activity, "setting_cache_shaders", true);
emuSettings.hard_fpu =
GetEffectivePrefBool(env, activity, "setting_hard_fpu", true);
emuSettings.skip_boot_anim =
GetPrefBool(env, activity, "setting_skip_boot_anim", false);
GetEffectivePrefBool(env, activity, "setting_skip_boot_anim", false);
emuSettings.network_enabled =
GetPrefBool(env, activity, "setting_network_enable", false);
GetEffectivePrefBool(env, activity, "setting_network_enable", false);
{
std::string tcgThread = GetPrefString(env, activity, "setting_tcg_thread");
std::string tcgThread =
GetEffectivePrefString(env, activity, "setting_tcg_thread");
if (tcgThread == "single") {
emuSettings.tcg_thread = "single";
}
}
{
std::string rendererPref = GetPrefString(env, activity, "setting_renderer");
std::string rendererPref =
GetEffectivePrefString(env, activity, "setting_renderer");
if (rendererPref == "vulkan") {
emuSettings.renderer = "vulkan";
} else if (rendererPref == "opengl") {
@@ -1303,13 +1386,16 @@ static SetupFiles SyncSetupFiles() {
}
}
{
std::string filteringPref = GetPrefString(env, activity, "setting_filtering");
std::string filteringPref =
GetEffectivePrefString(env, activity, "setting_filtering");
if (filteringPref == "nearest") {
emuSettings.filtering = "nearest";
}
}
emuSettings.vsync = GetPrefBool(env, activity, "setting_vsync", false);
out.audio_driver = GetPrefString(env, activity, "setting_audio_driver");
emuSettings.vsync =
GetEffectivePrefBool(env, activity, "setting_vsync", false);
out.audio_driver =
GetEffectivePrefString(env, activity, "setting_audio_driver");
{
std::string normalized = ToLowerAscii(out.audio_driver);
if (normalized == "android" || normalized == "audiotrack") {
@@ -1317,7 +1403,7 @@ static SetupFiles SyncSetupFiles() {
}
}
int displayMode = GetPrefInt(env, activity, "setting_display_mode", 0);
int displayMode = GetEffectivePrefInt(env, activity, "setting_display_mode", 0);
xemu_android_set_display_mode_setting(displayMode);
unsetenv("XEMU_VULKAN_DRIVER");
@@ -13,9 +13,15 @@ object FrontendLaunchHelper {
data class LaunchTarget(
val dvdUri: Uri? = null,
val dvdPath: String? = null,
val relativePath: String? = null,
val source: String
)
private data class TreeMatch(
val uri: Uri,
val relativePath: String,
)
private val stringExtraKeys = listOf(
"rom",
"ROM",
@@ -151,7 +157,11 @@ object FrontendLaunchHelper {
val treeMatch = resolvePathAgainstGamesFolder(context, gamesFolderUri, path)
if (treeMatch != null) {
return LaunchTarget(dvdUri = treeMatch, source = label)
return LaunchTarget(
dvdUri = treeMatch.uri,
relativePath = treeMatch.relativePath,
source = label,
)
}
return null
@@ -161,7 +171,7 @@ object FrontendLaunchHelper {
context: Context,
gamesFolderUri: Uri?,
rawPath: String
): Uri? {
): TreeMatch? {
val treeUri = gamesFolderUri ?: return null
val treeRootPath = treeUriToFilesystemPath(treeUri) ?: return null
val normalizedTree = normalizeFilesystemPath(treeRootPath)
@@ -175,7 +185,9 @@ object FrontendLaunchHelper {
var node = DocumentFile.fromTreeUri(context, treeUri) ?: return null
if (relativePath.isEmpty()) {
return node.takeIf { it.isFile }?.uri
return node.takeIf { it.isFile }?.let { file ->
TreeMatch(uri = file.uri, relativePath = "")
}
}
for (segment in relativePath.split('/')) {
@@ -184,7 +196,9 @@ object FrontendLaunchHelper {
}
node = node.findFile(segment) ?: return null
}
return node.takeIf { it.isFile }?.uri
return node.takeIf { it.isFile }?.let { file ->
TreeMatch(uri = file.uri, relativePath = relativePath)
}
}
private fun treeUriToFilesystemPath(treeUri: Uri): String? {
@@ -65,6 +65,7 @@ class GameLibraryActivity : AppCompatActivity() {
}
private enum class GameContextAction {
PER_GAME_SETTINGS,
SET_CUSTOM_COVER,
REMOVE_CUSTOM_COVER,
DELETE_GAME,
@@ -283,7 +284,13 @@ class GameLibraryActivity : AppCompatActivity() {
// MainActivity runs in :xemu, so the disc selection must be flushed before
// the other process reads SharedPreferences during startup.
prefs.edit()
val launchEditor = prefs.edit()
PerGameSettingsManager.applyRuntimeOverridesToEditor(
context = this,
editor = launchEditor,
relativePath = null,
)
launchEditor
.remove("dvdUri")
.remove("dvdPath")
.putBoolean("skip_game_picker", false)
@@ -1249,7 +1256,13 @@ class GameLibraryActivity : AppCompatActivity() {
persistUriPermission(game.uri)
// MainActivity runs in :xemu, so the disc selection must be flushed before
// the other process reads SharedPreferences during startup.
prefs.edit()
val launchEditor = prefs.edit()
PerGameSettingsManager.applyRuntimeOverridesToEditor(
context = this,
editor = launchEditor,
relativePath = game.relativePath,
)
launchEditor
.putString("dvdUri", game.uri.toString())
.remove("dvdPath")
.putBoolean("skip_game_picker", false)
@@ -1689,6 +1702,7 @@ class GameLibraryActivity : AppCompatActivity() {
if (customCover.exists()) {
customCover.delete()
}
PerGameSettingsManager.clearOverrides(this, game.relativePath)
boxArtCache.remove(normalizeCoverKey(game.title))
boxArtMisses.remove(normalizeCoverKey(game.title))
@@ -1699,6 +1713,7 @@ class GameLibraryActivity : AppCompatActivity() {
private fun showGameContextMenu(game: GameEntry) {
val hasCustomCover = getCustomCoverFile(game).exists()
val actions = buildList {
add(GameContextAction.PER_GAME_SETTINGS)
add(GameContextAction.SET_CUSTOM_COVER)
if (hasCustomCover) {
add(GameContextAction.REMOVE_CUSTOM_COVER)
@@ -1707,6 +1722,7 @@ class GameLibraryActivity : AppCompatActivity() {
}
val options = actions.map { action ->
when (action) {
GameContextAction.PER_GAME_SETTINGS -> getString(R.string.library_per_game_settings_option)
GameContextAction.SET_CUSTOM_COVER -> getString(R.string.library_custom_cover_set_option)
GameContextAction.REMOVE_CUSTOM_COVER -> getString(R.string.library_custom_cover_remove_option)
GameContextAction.DELETE_GAME -> getString(R.string.library_delete_option)
@@ -1730,6 +1746,13 @@ class GameLibraryActivity : AppCompatActivity() {
setOnClickListener {
contextDialog.dismiss()
when (actions[i]) {
GameContextAction.PER_GAME_SETTINGS -> {
startActivity(
Intent(this@GameLibraryActivity, PerGameSettingsActivity::class.java)
.putExtra(PerGameSettingsActivity.EXTRA_GAME_TITLE, game.title)
.putExtra(PerGameSettingsActivity.EXTRA_GAME_RELATIVE_PATH, game.relativePath)
)
}
GameContextAction.SET_CUSTOM_COVER -> {
pendingCustomCoverGame = game
pickCustomCover.launch("image/*")
@@ -96,21 +96,24 @@ class LauncherActivity : Activity() {
}
// MainActivity runs in :xemu, so launch data must be flushed before
// handing off to the emulator process.
prefs.edit()
.putBoolean("skip_game_picker", false)
.apply {
when {
frontendLaunch.dvdUri != null -> {
putString("dvdUri", frontendLaunch.dvdUri.toString())
remove("dvdPath")
}
frontendLaunch.dvdPath != null -> {
putString("dvdPath", frontendLaunch.dvdPath)
remove("dvdUri")
}
}
val launchEditor = prefs.edit()
launchEditor.putBoolean("skip_game_picker", false)
PerGameSettingsManager.applyRuntimeOverridesToEditor(
context = this,
editor = launchEditor,
relativePath = frontendLaunch.relativePath,
)
when {
frontendLaunch.dvdUri != null -> {
launchEditor.putString("dvdUri", frontendLaunch.dvdUri.toString())
launchEditor.remove("dvdPath")
}
.commit()
frontendLaunch.dvdPath != null -> {
launchEditor.putString("dvdPath", frontendLaunch.dvdPath)
launchEditor.remove("dvdUri")
}
}
launchEditor.commit()
if (hasMcpx && hasFlash && hasHdd) {
DebugLog.i(TAG) { "Frontend launch resolved via ${frontendLaunch.source}" }
@@ -58,7 +58,11 @@ object OrientationPreferences {
}
fun getGameOrientation(context: Context): GameOrientation {
return GameOrientation.fromPrefValue(sharedPreferences(context).getString(PREF_GAME_ORIENTATION, null))
val prefs = sharedPreferences(context)
val runtimeOverride = PerGameSettingsManager.getRuntimeOverride(context, PREF_GAME_ORIENTATION)
return GameOrientation.fromPrefValue(
runtimeOverride ?: prefs.getString(PREF_GAME_ORIENTATION, null)
)
}
fun getUiRequestedOrientation(context: Context): Int {
@@ -0,0 +1,281 @@
package com.izzy2lost.x1box
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputLayout
class PerGameSettingsActivity : AppCompatActivity() {
companion object {
const val EXTRA_GAME_TITLE = "com.izzy2lost.x1box.extra.GAME_TITLE"
const val EXTRA_GAME_RELATIVE_PATH = "com.izzy2lost.x1box.extra.GAME_RELATIVE_PATH"
}
private data class SettingOption(
val value: String?,
val labelRes: Int,
)
private data class SettingField(
val key: String,
val inputLayoutId: Int,
val dropdownId: Int,
val options: List<SettingOption>,
)
private val fieldSelections = linkedMapOf<String, String?>()
private val fields by lazy {
listOf(
SettingField(
key = "setting_renderer",
inputLayoutId = R.id.input_per_game_renderer,
dropdownId = R.id.dropdown_per_game_renderer,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("vulkan", R.string.settings_graphics_api_vulkan),
SettingOption("opengl", R.string.settings_graphics_api_opengl),
),
),
SettingField(
key = "setting_filtering",
inputLayoutId = R.id.input_per_game_filtering,
dropdownId = R.id.dropdown_per_game_filtering,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("linear", R.string.settings_filtering_linear),
SettingOption("nearest", R.string.settings_filtering_nearest),
),
),
SettingField(
key = "setting_vsync",
inputLayoutId = R.id.input_per_game_vsync,
dropdownId = R.id.dropdown_per_game_vsync,
options = booleanOptions(),
),
SettingField(
key = "setting_surface_scale",
inputLayoutId = R.id.input_per_game_surface_scale,
dropdownId = R.id.dropdown_per_game_surface_scale,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("1", R.string.settings_resolution_scale_1x),
SettingOption("2", R.string.settings_resolution_scale_2x),
SettingOption("3", R.string.settings_resolution_scale_3x),
),
),
SettingField(
key = "setting_display_mode",
inputLayoutId = R.id.input_per_game_display_mode,
dropdownId = R.id.dropdown_per_game_display_mode,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("0", R.string.settings_display_mode_stretch),
SettingOption("1", R.string.settings_display_mode_4_3),
SettingOption("2", R.string.settings_display_mode_16_9),
),
),
SettingField(
key = OrientationPreferences.PREF_GAME_ORIENTATION,
inputLayoutId = R.id.input_per_game_orientation,
dropdownId = R.id.dropdown_per_game_orientation,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption(
OrientationPreferences.GameOrientation.FOLLOW_DEVICE.prefValue,
R.string.settings_orientation_follow_device,
),
SettingOption(
OrientationPreferences.GameOrientation.LANDSCAPE.prefValue,
R.string.settings_orientation_landscape,
),
SettingOption(
OrientationPreferences.GameOrientation.REVERSE_LANDSCAPE.prefValue,
R.string.settings_orientation_reverse_landscape,
),
),
),
SettingField(
key = "setting_system_memory_mib",
inputLayoutId = R.id.input_per_game_system_memory,
dropdownId = R.id.dropdown_per_game_system_memory,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("64", R.string.settings_system_memory_64),
SettingOption("128", R.string.settings_system_memory_128),
),
),
SettingField(
key = "setting_tcg_thread",
inputLayoutId = R.id.input_per_game_tcg_thread,
dropdownId = R.id.dropdown_per_game_tcg_thread,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("multi", R.string.settings_tcg_thread_multi),
SettingOption("single", R.string.settings_tcg_thread_single),
),
),
SettingField(
key = "setting_hard_fpu",
inputLayoutId = R.id.input_per_game_hard_fpu,
dropdownId = R.id.dropdown_per_game_hard_fpu,
options = booleanOptions(),
),
SettingField(
key = "setting_cache_shaders",
inputLayoutId = R.id.input_per_game_cache_shaders,
dropdownId = R.id.dropdown_per_game_cache_shaders,
options = booleanOptions(),
),
SettingField(
key = "setting_skip_boot_anim",
inputLayoutId = R.id.input_per_game_skip_boot_anim,
dropdownId = R.id.dropdown_per_game_skip_boot_anim,
options = booleanOptions(),
),
SettingField(
key = "setting_use_dsp",
inputLayoutId = R.id.input_per_game_use_dsp,
dropdownId = R.id.dropdown_per_game_use_dsp,
options = booleanOptions(),
),
SettingField(
key = "setting_hrtf",
inputLayoutId = R.id.input_per_game_hrtf,
dropdownId = R.id.dropdown_per_game_hrtf,
options = booleanOptions(),
),
SettingField(
key = "setting_audio_driver",
inputLayoutId = R.id.input_per_game_audio_driver,
dropdownId = R.id.dropdown_per_game_audio_driver,
options = listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("openslES", R.string.settings_audio_driver_opensles),
SettingOption("aaudio", R.string.settings_audio_driver_aaudio),
SettingOption("dummy", R.string.settings_audio_driver_disabled),
),
),
SettingField(
key = "setting_network_enable",
inputLayoutId = R.id.input_per_game_network_enable,
dropdownId = R.id.dropdown_per_game_network_enable,
options = booleanOptions(),
),
)
}
private lateinit var gameTitle: String
private lateinit var relativePath: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
OrientationLocker(this).enable()
setContentView(R.layout.activity_per_game_settings)
EdgeToEdgeHelper.enable(this)
EdgeToEdgeHelper.applySystemBarPadding(findViewById(R.id.per_game_settings_scroll))
gameTitle = intent.getStringExtra(EXTRA_GAME_TITLE)?.trim().orEmpty()
relativePath = intent.getStringExtra(EXTRA_GAME_RELATIVE_PATH)?.trim().orEmpty()
if (relativePath.isEmpty()) {
Toast.makeText(this, R.string.per_game_settings_missing_game, Toast.LENGTH_SHORT).show()
finish()
return
}
findViewById<TextView>(R.id.tv_per_game_settings_game_title).text =
gameTitle.ifEmpty { relativePath.substringAfterLast('/') }
findViewById<TextView>(R.id.tv_per_game_settings_game_path).text = relativePath
val savedOverrides = PerGameSettingsManager.loadOverrides(this, relativePath)
bindFields(savedOverrides)
findViewById<MaterialButton>(R.id.btn_per_game_settings_clear).setOnClickListener {
PerGameSettingsManager.clearOverrides(this, relativePath)
Toast.makeText(this, R.string.per_game_settings_cleared, Toast.LENGTH_SHORT).show()
finish()
}
findViewById<MaterialButton>(R.id.btn_per_game_settings_save).setOnClickListener {
PerGameSettingsManager.saveOverrides(this, relativePath, fieldSelections)
Toast.makeText(this, R.string.per_game_settings_saved, Toast.LENGTH_SHORT).show()
finish()
}
}
private fun bindFields(savedOverrides: Map<String, String>) {
for (field in fields) {
val inputLayout = findViewById<TextInputLayout>(field.inputLayoutId)
val dropdown = findViewById<AutoCompleteTextView>(field.dropdownId)
val labels = field.options.map { option -> getString(option.labelRes) }
dropdown.setAdapter(ArrayAdapter(this, android.R.layout.simple_list_item_1, labels))
dropdown.setOnItemClickListener { _, _, position, _ ->
fieldSelections[field.key] = field.options[position].value
}
val selectedValue = savedOverrides[field.key]
fieldSelections[field.key] = selectedValue
setFieldSelection(dropdown, field, selectedValue)
inputLayout.helperText = getString(
R.string.per_game_settings_global_value,
describeGlobalValue(field),
)
}
}
private fun setFieldSelection(
dropdown: AutoCompleteTextView,
field: SettingField,
value: String?,
) {
val option = field.options.firstOrNull { it.value == value } ?: field.options.first()
dropdown.setText(getString(option.labelRes), false)
}
private fun describeGlobalValue(field: SettingField): String {
val globalValue = readGlobalValue(field.key)
val matchingOption = field.options.firstOrNull { option -> option.value == globalValue }
?: field.options.first()
return getString(matchingOption.labelRes)
}
private fun readGlobalValue(key: String): String {
val prefs = getSharedPreferences("x1box_prefs", MODE_PRIVATE)
return when (key) {
"setting_renderer" -> prefs.getString(key, "opengl") ?: "opengl"
"setting_filtering" -> prefs.getString(key, "linear") ?: "linear"
"setting_vsync" -> prefs.getBoolean(key, false).toString()
"setting_surface_scale" -> prefs.getInt(key, 1).toString()
"setting_display_mode" -> prefs.getInt(key, 0).toString()
OrientationPreferences.PREF_GAME_ORIENTATION ->
prefs.getString(
key,
OrientationPreferences.GameOrientation.FOLLOW_DEVICE.prefValue,
) ?: OrientationPreferences.GameOrientation.FOLLOW_DEVICE.prefValue
"setting_system_memory_mib" -> prefs.getInt(key, 64).toString()
"setting_tcg_thread" -> prefs.getString(key, "multi") ?: "multi"
"setting_use_dsp" -> prefs.getBoolean(key, false).toString()
"setting_hrtf" -> prefs.getBoolean(key, false).toString()
"setting_cache_shaders" -> prefs.getBoolean(key, true).toString()
"setting_hard_fpu" -> prefs.getBoolean(key, true).toString()
"setting_skip_boot_anim" -> prefs.getBoolean(key, false).toString()
"setting_audio_driver" -> prefs.getString(key, "openslES") ?: "openslES"
"setting_network_enable" -> prefs.getBoolean(key, false).toString()
else -> ""
}
}
private fun booleanOptions(): List<SettingOption> {
return listOf(
SettingOption(null, R.string.per_game_settings_use_global),
SettingOption("true", R.string.per_game_settings_enabled),
SettingOption("false", R.string.per_game_settings_disabled),
)
}
}
@@ -0,0 +1,126 @@
package com.izzy2lost.x1box
import android.content.Context
import android.content.SharedPreferences
import java.security.MessageDigest
import java.util.Locale
object PerGameSettingsManager {
private const val APP_PREFS_NAME = "x1box_prefs"
private const val STORE_PREFS_NAME = "x1box_per_game_settings"
private const val STORE_KEY_PREFIX = "game_override:"
private const val RUNTIME_KEY_PREFIX = "runtime_override_"
val overridablePreferenceKeys = listOf(
"setting_renderer",
"setting_filtering",
"setting_vsync",
"setting_surface_scale",
"setting_display_mode",
OrientationPreferences.PREF_GAME_ORIENTATION,
"setting_system_memory_mib",
"setting_tcg_thread",
"setting_use_dsp",
"setting_hrtf",
"setting_cache_shaders",
"setting_hard_fpu",
"setting_skip_boot_anim",
"setting_audio_driver",
"setting_network_enable",
)
fun hasOverrides(context: Context, relativePath: String): Boolean {
val prefs = storePreferences(context)
val gameId = gameId(relativePath)
return overridablePreferenceKeys.any { key ->
prefs.contains(storageKey(gameId, key))
}
}
fun loadOverrides(context: Context, relativePath: String): Map<String, String> {
val prefs = storePreferences(context)
val gameId = gameId(relativePath)
return buildMap {
for (key in overridablePreferenceKeys) {
prefs.getString(storageKey(gameId, key), null)
?.takeIf { value -> value.isNotEmpty() }
?.let { value -> put(key, value) }
}
}
}
fun saveOverrides(
context: Context,
relativePath: String,
overrides: Map<String, String?>,
) {
val prefs = storePreferences(context)
val gameId = gameId(relativePath)
val editor = prefs.edit()
for (key in overridablePreferenceKeys) {
val value = overrides[key]
if (value.isNullOrEmpty()) {
editor.remove(storageKey(gameId, key))
} else {
editor.putString(storageKey(gameId, key), value)
}
}
editor.apply()
}
fun clearOverrides(context: Context, relativePath: String) {
val prefs = storePreferences(context)
val gameId = gameId(relativePath)
val editor = prefs.edit()
for (key in overridablePreferenceKeys) {
editor.remove(storageKey(gameId, key))
}
editor.apply()
}
fun applyRuntimeOverridesToEditor(
context: Context,
editor: SharedPreferences.Editor,
relativePath: String?,
) {
val overrides = relativePath
?.takeIf { path -> path.isNotBlank() }
?.let { path -> loadOverrides(context, path) }
.orEmpty()
for (key in overridablePreferenceKeys) {
val runtimeKey = runtimeKey(key)
val value = overrides[key]
if (value.isNullOrEmpty()) {
editor.remove(runtimeKey)
} else {
editor.putString(runtimeKey, value)
}
}
}
fun getRuntimeOverride(context: Context, key: String): String? {
return appPreferences(context).getString(runtimeKey(key), null)
?.takeIf { value -> value.isNotEmpty() }
}
fun runtimeKey(key: String): String = RUNTIME_KEY_PREFIX + key
private fun gameId(relativePath: String): String {
val normalized = relativePath.trim().lowercase(Locale.ROOT)
val digest = MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(normalized.toByteArray(Charsets.UTF_8))
return bytes.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xFF) }
}
private fun storageKey(gameId: String, key: String): String =
STORE_KEY_PREFIX + gameId + ":" + key
private fun appPreferences(context: Context): SharedPreferences {
return context.applicationContext.getSharedPreferences(APP_PREFS_NAME, Context.MODE_PRIVATE)
}
private fun storePreferences(context: Context): SharedPreferences {
return context.applicationContext.getSharedPreferences(STORE_PREFS_NAME, Context.MODE_PRIVATE)
}
}
@@ -1534,7 +1534,13 @@ class SettingsActivity : AppCompatActivity() {
private fun launchInsigniaSetupAssistant(uri: Uri) {
persistUriPermission(uri)
switchNetworkEnable.isChecked = true
prefs.edit()
val launchEditor = prefs.edit()
PerGameSettingsManager.applyRuntimeOverridesToEditor(
context = this,
editor = launchEditor,
relativePath = null,
)
launchEditor
.putBoolean("setting_network_enable", true)
.putString("dvdUri", uri.toString())
.remove("dvdPath")
@@ -0,0 +1,408 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/setup_wizard_background">
<androidx.core.widget.NestedScrollView
android:id="@+id/per_game_settings_scroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingHorizontal="@dimen/top_level_card_outer_padding_horizontal"
android:paddingVertical="@dimen/top_level_card_outer_padding_vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/xemu_surface"
app:cardCornerRadius="28dp"
app:cardElevation="14dp"
app:strokeColor="@color/xemu_outline"
app:strokeWidth="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/per_game_settings_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium" />
<TextView
android:id="@+id/tv_per_game_settings_game_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
android:textColor="@color/xemu_green_light" />
<TextView
android:id="@+id/tv_per_game_settings_game_path"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="16dp"
android:text="@string/per_game_settings_intro"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/section_header_background"
android:text="@string/settings_section_display"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="@color/xemu_green_light" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_renderer"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_graphics_api">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_renderer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_filtering"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_filtering">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_filtering"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_vsync"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_vsync">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_vsync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_surface_scale"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_resolution_scale">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_surface_scale"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_display_mode"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_display_mode">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_display_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_orientation"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:hint="@string/settings_in_game_orientation">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_orientation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/settings_in_game_orientation_hint"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="16dp"
android:background="@color/xemu_outline_variant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/section_header_background"
android:text="@string/settings_section_performance"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="@color/xemu_green_light" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_system_memory"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_system_memory">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_system_memory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_tcg_thread"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_tcg_thread">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_tcg_thread"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_hard_fpu"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_hard_fpu">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_hard_fpu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_cache_shaders"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_cache_shaders">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_cache_shaders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_skip_boot_anim"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="@string/settings_skip_boot_anim">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_skip_boot_anim"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="16dp"
android:background="@color/xemu_outline_variant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/section_header_background"
android:text="@string/settings_section_audio"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="@color/xemu_green_light" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_use_dsp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_use_dsp">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_use_dsp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_hrtf"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/settings_hrtf">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_hrtf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_audio_driver"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="@string/settings_audio_driver">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_audio_driver"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="16dp"
android:background="@color/xemu_outline_variant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/section_header_background"
android:text="@string/settings_section_online"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="@color/xemu_green_light" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_per_game_network_enable"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
android:hint="@string/settings_online_enable">
<AutoCompleteTextView
android:id="@+id/dropdown_per_game_network_enable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_per_game_settings_clear"
style="@style/Widget.Xemu.Button.Outlined.Pill"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:text="@string/per_game_settings_clear" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_per_game_settings_save"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:text="@string/per_game_settings_save"
android:textColor="@color/xemu_black" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -345,4 +345,17 @@
<string name="library_custom_cover_set">Custom cover saved</string>
<string name="library_custom_cover_removed">Custom cover removed</string>
<string name="library_custom_cover_failed">Failed to save custom cover</string>
<string name="library_per_game_settings_option">Per-Game Settings</string>
<string name="per_game_settings_title">Per-Game Settings</string>
<string name="per_game_settings_intro">Choose only the settings this game should override. Leave a field on Use Global to keep the normal app setting.</string>
<string name="per_game_settings_use_global">Use Global</string>
<string name="per_game_settings_global_value">Global default: %1$s</string>
<string name="per_game_settings_enabled">Enabled</string>
<string name="per_game_settings_disabled">Disabled</string>
<string name="per_game_settings_save">Save Overrides</string>
<string name="per_game_settings_clear">Clear Overrides</string>
<string name="per_game_settings_saved">Per-game settings saved</string>
<string name="per_game_settings_cleared">Per-game settings cleared</string>
<string name="per_game_settings_missing_game">This game could not be resolved.</string>
</resources>