diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 2215c48ddc..2dc236f7a6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -61,5 +61,10 @@
android:screenOrientation="fullSensor"
android:exported="false" />
+
+
diff --git a/android/app/src/main/cpp/xemu_android.cpp b/android/app/src/main/cpp/xemu_android.cpp
index 2bfb2149da..1b83c40865 100644
--- a/android/app/src/main/cpp/xemu_android.cpp
+++ b/android/app/src/main/cpp/xemu_android.cpp
@@ -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(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");
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt b/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt
index 75c6a36fc9..54d9f1b1e4 100644
--- a/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt
+++ b/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt
@@ -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? {
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt
index 7f87ef1de7..3eb73d6bd3 100644
--- a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt
+++ b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt
@@ -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/*")
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt
index 7ac92141d9..3c9be6cdfc 100644
--- a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt
+++ b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt
@@ -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}" }
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt b/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt
index fe0e8f56c8..e5fd5b6b13 100644
--- a/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt
+++ b/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt
@@ -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 {
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsActivity.kt
new file mode 100644
index 0000000000..6ec698a3ff
--- /dev/null
+++ b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsActivity.kt
@@ -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,
+ )
+
+ private val fieldSelections = linkedMapOf()
+
+ 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(R.id.tv_per_game_settings_game_title).text =
+ gameTitle.ifEmpty { relativePath.substringAfterLast('/') }
+ findViewById(R.id.tv_per_game_settings_game_path).text = relativePath
+
+ val savedOverrides = PerGameSettingsManager.loadOverrides(this, relativePath)
+ bindFields(savedOverrides)
+
+ findViewById(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(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) {
+ for (field in fields) {
+ val inputLayout = findViewById(field.inputLayoutId)
+ val dropdown = findViewById(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 {
+ 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),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsManager.kt b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsManager.kt
new file mode 100644
index 0000000000..93777d647a
--- /dev/null
+++ b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsManager.kt
@@ -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 {
+ 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,
+ ) {
+ 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)
+ }
+}
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt
index 6fd48f9b05..d3b86998e5 100644
--- a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt
+++ b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt
@@ -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")
diff --git a/android/app/src/main/res/layout/activity_per_game_settings.xml b/android/app/src/main/res/layout/activity_per_game_settings.xml
new file mode 100644
index 0000000000..ad705545de
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_per_game_settings.xml
@@ -0,0 +1,408 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 5d8762de25..8758577a65 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -345,4 +345,17 @@
Custom cover saved
Custom cover removed
Failed to save custom cover
+ Per-Game Settings
+
+ Per-Game Settings
+ Choose only the settings this game should override. Leave a field on Use Global to keep the normal app setting.
+ Use Global
+ Global default: %1$s
+ Enabled
+ Disabled
+ Save Overrides
+ Clear Overrides
+ Per-game settings saved
+ Per-game settings cleared
+ This game could not be resolved.