mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
EpicDownloadManager runs EpicNative.stream when the library loads and its per-volume space plan fits (cache on internal scratch for card installs, beside the game when internal storage is full), and falls back to the bounded Java writer otherwise. A hidden storesEpicMode preference selects adaptive, batch (whole-cache fetch then native assembly, with its 2x space check) or java for A/B runs; each mode logs a summary line.
1098 lines
54 KiB
Kotlin
1098 lines
54 KiB
Kotlin
package com.droiddeck.launcher.session
|
||
|
||
import android.content.Context
|
||
import android.content.pm.ActivityInfo
|
||
import androidx.annotation.StringRes
|
||
import com.droiddeck.launcher.R
|
||
import com.droiddeck.launcher.core.CpuAllocationDefaults
|
||
import com.droiddeck.launcher.core.GameEnvironment
|
||
import com.droiddeck.launcher.core.TextureFiltering
|
||
import com.droiddeck.launcher.core.ZramSupport
|
||
import com.droiddeck.launcher.gpu.Cardboard
|
||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||
import org.json.JSONObject
|
||
|
||
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
|
||
object SessionPrefs {
|
||
data class GameTextureFiltering(val anisotropy: Int? = null, val lodBias: String? = null)
|
||
|
||
const val SUSPEND_AUTO = "auto"
|
||
const val SUSPEND_NATIVE = "native"
|
||
const val SUSPEND_MANUAL = "manual"
|
||
const val SUSPEND_NEVER = "never"
|
||
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
|
||
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
|
||
|
||
const val CONTROLLER_DECK = "deck"
|
||
const val CONTROLLER_XBOX360 = "xbox360"
|
||
const val CONTROLLER_STEAM = "steamcontroller"
|
||
const val CONTROLLER_DS4 = "dualshock4"
|
||
const val CONTROLLER_DUALSENSE = "dualsense"
|
||
const val OSC_AUTO = "auto"
|
||
const val OSC_ALWAYS = "always"
|
||
const val OSC_STEAM_QAM = "steam-qam"
|
||
const val OSC_NEVER = "never"
|
||
/** Steam's touch controller (SteamTouchControls) in place of the app's pad; Steam sessions only. */
|
||
const val OSC_STEAM_TOUCH = "steam-touch"
|
||
|
||
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
|
||
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
|
||
val BACK_QAM_THEN_MENU = R.string.back_qam_then_menu
|
||
|
||
@StringRes
|
||
fun backActionsOrder(inverted: Boolean): Int =
|
||
if (inverted) BACK_QAM_THEN_MENU else BACK_MENU_THEN_QAM
|
||
|
||
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||
|
||
val orientationOptions = listOf(
|
||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
|
||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
|
||
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to R.string.setup_orientation_reverse,
|
||
)
|
||
|
||
fun orientation(context: Context): Int =
|
||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||
.takeIf { value -> orientationOptions.any { it.first == value } }
|
||
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||
|
||
fun setOrientation(context: Context, orientation: Int) {
|
||
require(orientationOptions.any { it.first == orientation })
|
||
prefs(context).edit().putInt("orientation", orientation).apply()
|
||
}
|
||
|
||
/** Whether the launcher hides Android's status and navigation bars. */
|
||
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
|
||
|
||
fun setLauncherFullscreen(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
|
||
}
|
||
|
||
fun animationsEnabled(context: Context): Boolean = prefs(context).getBoolean("animations", true)
|
||
|
||
fun setAnimationsEnabled(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("animations", on).apply()
|
||
}
|
||
|
||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
|
||
|
||
fun setGameStoresEnabled(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
|
||
}
|
||
|
||
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
|
||
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
|
||
|
||
fun setGameStoresSpeedTier(context: Context, tier: String) {
|
||
prefs(context).edit().putString("gameStoresSpeedTier", tier).apply()
|
||
}
|
||
|
||
/**
|
||
* Hidden A/B switch for the Epic installer: "adaptive" (default: native parallel fetch written
|
||
* straight into staged files), "batch" (the older whole-cache native fetch, then native
|
||
* assembly; needs about twice the game's size free) or "java" (the bounded one-chunk-at-a-time
|
||
* Java writer). No UI; set it in the `session` preferences for benchmarks.
|
||
*/
|
||
fun storesEpicMode(context: Context): String = prefs(context).getString("storesEpicMode", "adaptive") ?: "adaptive"
|
||
|
||
/** Which tab a signed-in store opens on when its chip is picked: [STORES_OPEN_LIBRARY] or [STORES_OPEN_STORE]. */
|
||
fun storesOpenTab(context: Context): String =
|
||
prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY
|
||
|
||
fun setStoresOpenTab(context: Context, tab: String) {
|
||
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
|
||
}
|
||
|
||
/** Mature titles (the stores' own 17+/18 ratings or adult tags) in the storefront's shelves and search; off by default. Owned games always show. */
|
||
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
|
||
|
||
fun setStoresShowMature(context: Context, show: Boolean) {
|
||
prefs(context).edit().putBoolean("storesShowMature", show).apply()
|
||
}
|
||
|
||
const val STORES_OPEN_LIBRARY = "library"
|
||
const val STORES_OPEN_STORE = "store"
|
||
|
||
/** The install root picked last time the Stores asked where (a StoreInstallRoot target's path); only the dialog's default, never a silent choice. */
|
||
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
|
||
|
||
fun setStoresInstallTarget(context: Context, path: String) {
|
||
prefs(context).edit().putString("storesInstallTarget", path).apply()
|
||
}
|
||
|
||
/** How many store downloads run at once, 1..3; one by default (a single download already fills the link). */
|
||
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
|
||
|
||
fun setGameStoresParallel(context: Context, count: Int) {
|
||
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
|
||
}
|
||
|
||
/**
|
||
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
|
||
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
|
||
* in one - a choice kept apart from every other session's, where it stays on by default.
|
||
*/
|
||
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
|
||
|
||
fun setHudEnabled(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean(hudKey(context), on).apply()
|
||
}
|
||
|
||
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
|
||
|
||
/** A Steam session with Deck mode's performance overlay (mangoapp). */
|
||
private fun mangoappSession(context: Context) =
|
||
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
|
||
|
||
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
|
||
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
|
||
|
||
fun setBackActionsInverted(context: Context, inverted: Boolean) {
|
||
prefs(context).edit().putBoolean("backActionsInverted", inverted).apply()
|
||
}
|
||
|
||
const val TOUCH_AUTO = "auto"
|
||
const val TOUCH_PAD = "touchpad"
|
||
const val TOUCH_DIRECT = "direct"
|
||
const val TOUCH_OFF = "off"
|
||
|
||
/** How touch drives the pointer: a touchpad (drag moves it from where it is) or direct
|
||
* (it jumps under the finger). Auto = touchpad on the desktop, direct in Steam.
|
||
* Off ignores touches on the guest picture; Android controls remain usable. */
|
||
fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO
|
||
|
||
fun setTouchMode(context: Context, mode: String) {
|
||
prefs(context).edit().putString("touch", mode).apply()
|
||
}
|
||
|
||
const val SHAPE_AUTO = "auto"
|
||
const val SHAPE_WIDE = "16:9"
|
||
const val SHAPE_EXACT = "exact"
|
||
|
||
/**
|
||
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
|
||
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
|
||
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
|
||
* display cannot change size once the session is up.
|
||
*/
|
||
private fun shapeMode(context: Context): String =
|
||
prefs(context).getString("shape", null)
|
||
?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO
|
||
|
||
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
|
||
|
||
fun setOscMode(context: Context, mode: String) {
|
||
prefs(context).edit().putString("osc", mode).apply()
|
||
}
|
||
|
||
/**
|
||
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
|
||
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
|
||
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
|
||
* session's choice, the one nearly everyone set, carries over.
|
||
*/
|
||
fun linuxDriver(context: Context): String =
|
||
prefs(context).getString("linuxDriver", null)
|
||
?: prefs(context).getString("linuxDriver.steam", null)
|
||
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
|
||
|
||
fun setLinuxDriver(context: Context, id: String) {
|
||
prefs(context).edit().putString("linuxDriver", id).apply()
|
||
}
|
||
|
||
const val GPU_DRIVERS_AUTO = "auto"
|
||
const val GPU_DRIVERS_MANUAL = "manual"
|
||
|
||
/**
|
||
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
|
||
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
|
||
* installs; see settleGpuDriverMode.
|
||
*/
|
||
fun gpuDriverMode(context: Context): String =
|
||
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
|
||
|
||
fun setGpuDriverMode(context: Context, mode: String) {
|
||
prefs(context).edit().putString("gpuDriverMode", mode).apply()
|
||
}
|
||
|
||
/**
|
||
* Auto arrived after people had picked drivers by hand: an install that chose either driver
|
||
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
|
||
*/
|
||
fun settleGpuDriverMode(context: Context) {
|
||
val p = prefs(context)
|
||
if (p.contains("gpuDriverMode")) return
|
||
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
|
||
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
|
||
}
|
||
|
||
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
|
||
fun gpuAutoInstalled(context: Context): Set<String> =
|
||
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
|
||
|
||
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
|
||
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
|
||
}
|
||
|
||
// The Steam client's own sound has one route now: DirectAudio's engine inside the PulseAudio
|
||
// daemon (PulseAudioComponent), one step from Android. The old "clientDirectAudio" boolean
|
||
// (which meant the relay route, one hop more) is no longer read.
|
||
|
||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||
|
||
fun setStretch16x9(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("stretch16x9", on).apply()
|
||
}
|
||
|
||
|
||
/**
|
||
* DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On
|
||
* unless the user turned it off.
|
||
*/
|
||
fun directAudio(context: Context): Boolean = prefs(context).getBoolean("directAudio", true)
|
||
|
||
fun setDirectAudio(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("directAudio", on).apply()
|
||
}
|
||
|
||
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
|
||
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
|
||
|
||
/** Whether the app has already asked for the microphone once at start-up. */
|
||
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
|
||
|
||
fun setMicAsked(context: Context) {
|
||
prefs(context).edit().putBoolean("micAsked", true).apply()
|
||
}
|
||
|
||
fun setMicEnabled(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("mic", on).apply()
|
||
}
|
||
|
||
/** Optional SSID reporting and scans. A Location grant alone never opts the user in. */
|
||
fun wifiDiscoveryEnabled(context: Context): Boolean = prefs(context).getBoolean("wifiDiscovery", false)
|
||
|
||
fun setWifiDiscoveryEnabled(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("wifiDiscovery", on).apply()
|
||
}
|
||
|
||
fun wifiDiscoveryAsked(context: Context): Boolean = prefs(context).getBoolean("wifiDiscoveryAsked", false)
|
||
|
||
fun setWifiDiscoveryAsked(context: Context) {
|
||
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply()
|
||
}
|
||
|
||
internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
|
||
|
||
/** Defaults are read only for absent keys; even a saved empty/all-core choice wins. */
|
||
internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
|
||
val saved = prefs(context)
|
||
return CoreAllocation(
|
||
saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
|
||
saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
|
||
saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
|
||
)
|
||
}
|
||
|
||
/** Use the device default until the user saves an override or a client mask. */
|
||
fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
|
||
|
||
fun setClientCpusOverride(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
|
||
}
|
||
|
||
/** Comma-separated core list for the client (taskset -c syntax); "" = every core. */
|
||
fun clientCpus(context: Context): String = coreAllocation(context).clientCpus
|
||
|
||
fun setClientCpus(context: Context, list: String) {
|
||
prefs(context).edit().putString("clientCpus", list).apply()
|
||
}
|
||
|
||
/** Comma-separated core list for games (taskset -c syntax); "" = every core (explicitly restored when the client is pinned). */
|
||
fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
|
||
|
||
fun setGameCpus(context: Context, list: String) {
|
||
prefs(context).edit().putString("gameCpus", list).apply()
|
||
}
|
||
|
||
/**
|
||
* Whether Steam's xalia helper is kept out of the session (PROTON_USE_XALIA=0).
|
||
*
|
||
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
|
||
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
|
||
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
|
||
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
|
||
* does run, it sits beside every game under FEX for nothing a controller-first session needs
|
||
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
|
||
*/
|
||
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
|
||
|
||
fun setNoXalia(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("noXalia", on).apply()
|
||
}
|
||
|
||
fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", false)
|
||
|
||
fun setFastSync(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("fastSync", on).apply()
|
||
}
|
||
|
||
fun fsyncFirst(context: Context): Boolean = prefs(context).getBoolean("fsyncFirst", false)
|
||
|
||
fun setFsyncFirst(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("fsyncFirst", on).apply()
|
||
}
|
||
|
||
const val SYNC_ESYNC = "esync"
|
||
const val SYNC_NTSYNC = "ntsync"
|
||
const val SYNC_FSYNC = "fsync"
|
||
const val SYNC_WINESERVER = "wineserver"
|
||
|
||
/**
|
||
* The sync Proton games use, as the three switches above decide it: droiddeck-ntsync wins
|
||
* while it is on, then droiddeck-fsync first, then droiddeck-esync, and wineserver alone while all three are off.
|
||
*/
|
||
fun syncBackend(context: Context): String = syncBackendOf(fastSync(context), fsyncFirst(context), syncFallback(context))
|
||
|
||
fun syncBackendOf(fastSync: Boolean, fsyncFirst: Boolean, syncFallback: Boolean): String = when {
|
||
fastSync -> SYNC_NTSYNC
|
||
fsyncFirst -> SYNC_FSYNC
|
||
syncFallback -> SYNC_ESYNC
|
||
else -> SYNC_WINESERVER
|
||
}
|
||
|
||
/** Picks one sync for Proton games; the switches change together, in one write. */
|
||
fun setSyncBackend(context: Context, backend: String) {
|
||
require(backend == SYNC_ESYNC || backend == SYNC_NTSYNC || backend == SYNC_FSYNC || backend == SYNC_WINESERVER) { "unknown sync $backend" }
|
||
prefs(context).edit()
|
||
.putBoolean("fastSync", backend == SYNC_NTSYNC)
|
||
.putBoolean("fsyncFirst", backend == SYNC_FSYNC)
|
||
.putBoolean("syncFallback", backend != SYNC_WINESERVER)
|
||
.apply()
|
||
}
|
||
|
||
fun syncFallback(context: Context): Boolean = prefs(context).getBoolean("syncFallback", true)
|
||
|
||
fun setSyncFallback(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("syncFallback", on).apply()
|
||
}
|
||
|
||
/**
|
||
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
|
||
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
|
||
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
|
||
* whose GPU is waiting on the CPU.
|
||
*/
|
||
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
|
||
|
||
fun setGamescopeRealtime(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
|
||
}
|
||
|
||
/**
|
||
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
|
||
*
|
||
* proot normally installs a seccomp filter so only the syscalls it must rewrite stop in the
|
||
* tracer; everything else runs untraced, which is most of proot's speed. Where a vendor kernel
|
||
* handles that filter badly the wrong calls are trapped or refused - ENOSYS from calls that
|
||
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
|
||
* but correct. Max's advice for devices whose kernels "don't work well with it".
|
||
*/
|
||
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
|
||
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
|
||
|
||
fun setGpuClockPin(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
|
||
}
|
||
|
||
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
|
||
|
||
fun setProotNoSeccomp(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
|
||
}
|
||
|
||
/**
|
||
* Whether the session's path lookups take proot's fast path (ProotFastPath): answered inside
|
||
* each process instead of a round trip through the tracer. On by default; it needs proot's
|
||
* seccomp filter, so it is off whenever proot runs without one.
|
||
*/
|
||
fun prootFastPath(context: Context): Boolean = prefs(context).getBoolean("prootFastPath", true)
|
||
|
||
fun setProotFastPath(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("prootFastPath", on).apply()
|
||
}
|
||
|
||
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
|
||
|
||
@JvmStatic
|
||
fun guestHostname(context: Context): String =
|
||
validGuestHostname(prefs(context).getString("guestHostname", null)) ?: DEFAULT_GUEST_HOSTNAME
|
||
|
||
fun setGuestHostname(context: Context, name: String) {
|
||
val valid = validGuestHostname(name)
|
||
prefs(context).edit().apply { if (valid == null) remove("guestHostname") else putString("guestHostname", valid) }.apply()
|
||
}
|
||
|
||
fun validGuestHostname(name: String?): String? {
|
||
val trimmed = name?.trim() ?: return null
|
||
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
|
||
}
|
||
|
||
/**
|
||
* Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling.
|
||
* On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the
|
||
* client's interface as many small render passes, each paying GMEM's load/store and binning,
|
||
* and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3).
|
||
*/
|
||
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true)
|
||
|
||
fun setTuSysmem(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("tuSysmem", on).apply()
|
||
}
|
||
|
||
/*
|
||
* The client's interface is drawn Chromium -> ANGLE -> Zink -> Turnip, and that chain is what
|
||
* limits its menus (~14 fps on an Adreno 840 while a game ran 89). These make the chain
|
||
* cheaper rather than asking for more; the three environment switches are on by default,
|
||
* Deck mode off. Bannerlator's LinuxTuning, carried over; none device-proven here yet.
|
||
*/
|
||
/** mesa_glthread=true: GL marshalled off the calling thread. */
|
||
fun glThread(context: Context): Boolean = prefs(context).getBoolean("glThread", true)
|
||
fun setGlThread(context: Context, on: Boolean) { prefs(context).edit().putBoolean("glThread", on).apply() }
|
||
|
||
/** MESA_NO_ERROR=1: no GL error checking. */
|
||
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
|
||
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
|
||
|
||
/**
|
||
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
|
||
* button, gyro, Steam Input's full treatment - SteamDeckPad), [CONTROLLER_STEAM], a 2025 Steam
|
||
* Controller (the same, plus grip sense and its own haptics), [CONTROLLER_DS4] or
|
||
* [CONTROLLER_DUALSENSE], a PlayStation pad with its touchpad fed by the trackpads, or
|
||
* [CONTROLLER_XBOX360], the plain Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||
*/
|
||
fun steamController(context: Context): String =
|
||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||
/** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */
|
||
fun padType(id: String): Int = when (id) {
|
||
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
|
||
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
|
||
CONTROLLER_DS4 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4
|
||
CONTROLLER_DUALSENSE -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE
|
||
else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK
|
||
}
|
||
|
||
/**
|
||
* Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws
|
||
* through it. Off is the way out where Valve's mangoapp crashes (one Turnip build did).
|
||
*/
|
||
fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true)
|
||
fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() }
|
||
|
||
/**
|
||
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
|
||
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
|
||
*/
|
||
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
|
||
|
||
fun setZinkLazy(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("zinkLazy", on).apply()
|
||
}
|
||
|
||
/** The Android driver the compositor loads: "" = pick by GPU, else a bundled or imported id. */
|
||
@JvmStatic
|
||
fun androidDriver(context: Context): String = prefs(context).getString("androidDriver", "") ?: ""
|
||
|
||
@JvmStatic
|
||
fun setAndroidDriver(context: Context, id: String) {
|
||
prefs(context).edit().putString("androidDriver", id).apply()
|
||
}
|
||
|
||
// ── Storage the session can see ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The folder on this device that every session shows at `/root/ROMs`, for the emulators on the
|
||
* desktop. "" = none chosen. All of internal storage is at `/root/Storage` regardless.
|
||
*/
|
||
fun romsDir(context: Context): String =
|
||
prefs(context).getString("romsDir", "") ?: ""
|
||
|
||
fun setRomsDir(context: Context, path: String) {
|
||
prefs(context).edit().putString("romsDir", path).apply()
|
||
}
|
||
|
||
/**
|
||
* Whether a session keeps its folder under files/logs. Off, the same logs are kept in
|
||
* the app's cache for the session's lifetime (the scripts need somewhere to write) and thrown
|
||
* away at the end, so nothing accumulates in Downloads.
|
||
*/
|
||
fun logsEnabled(context: Context): Boolean =
|
||
prefs(context).getBoolean("logs", true)
|
||
|
||
fun setLogsEnabled(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("logs", on).apply()
|
||
}
|
||
|
||
/** Steam storage-call diagnostics are opt-in because they add timing work to file operations. */
|
||
fun storageDiagnosticsEnabled(context: Context): Boolean =
|
||
prefs(context).getBoolean("storageDiagnostics", false)
|
||
|
||
fun setStorageDiagnosticsEnabled(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("storageDiagnostics", on).apply()
|
||
}
|
||
|
||
// ── Per-mode display ────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The tallest the session's display may be, in pixels, for MODE_STEAM / MODE_DESKTOP:
|
||
* 0 = the panel's own height, otherwise a cap. Both modes default to 720p: the client's CEF
|
||
* is the heaviest thing in a session, Big Picture is drawn for a TV at arm's length, and on
|
||
* a handheld panel 720p is where its menus stay responsive on a regular flagship; the desktop
|
||
* and the emulators under it get the same GPU headroom. Read once, when the session's display
|
||
* is sized; a cap the user chose wins over the default.
|
||
*/
|
||
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
|
||
|
||
/** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */
|
||
fun resolutionChosen(context: Context, mode: String): Boolean =
|
||
prefs(context).contains("displayResolution.$mode") ||
|
||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
|
||
|
||
/** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */
|
||
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
|
||
val saved = prefs(context)
|
||
saved.getString("displayResolution.$mode", null)?.let { value ->
|
||
if (value == SessionDisplay.MATCH_SCREEN || value == SessionDisplay.FOLLOW_SCREEN ||
|
||
SessionDisplay.presetHeight(value) != null) return value
|
||
parseResolution(value)?.let { return "${it.first}x${it.second}" }
|
||
}
|
||
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
|
||
val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode))
|
||
return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN
|
||
else "${legacy.first}x${legacy.second}"
|
||
}
|
||
|
||
fun setResolutionChoice(context: Context, mode: String, choice: String) {
|
||
val value = if (choice == SessionDisplay.MATCH_SCREEN || choice == SessionDisplay.FOLLOW_SCREEN ||
|
||
SessionDisplay.presetHeight(choice) != null) choice else {
|
||
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
|
||
"${size.first}x${size.second}"
|
||
}
|
||
prefs(context).edit().putString("displayResolution.$mode", value).apply()
|
||
}
|
||
|
||
/**
|
||
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
|
||
* Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO
|
||
* and full-precision x87, which cost every x86 game time; a game that needs them can still be
|
||
* given another preset.
|
||
*/
|
||
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET
|
||
|
||
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
|
||
|
||
/**
|
||
* Force SSBS for Proton games: Wine resumes threads from a Windows CONTEXT that never carries
|
||
* PSTATE.SSBS, so they run with speculative store bypass disabled; libssbs.so keeps it set
|
||
* (on DiRT 3 / GE-Proton: from ~99% of a game's threads running without it to none). The
|
||
* speed-up is reported on Oryon cores (Snapdragon 8 Elite) and was not measurable on an
|
||
* 8 Gen 3, so it is off unless turned on. A game's own environment can still say
|
||
* DROIDDECK_FORCE_SSBS=0.
|
||
*/
|
||
fun forceSsbs(context: Context): Boolean = prefs(context).getBoolean("forceSsbs", false)
|
||
|
||
fun setForceSsbs(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("forceSsbs", on).apply()
|
||
runCatching { GameEnvironmentStore.publish(context) }
|
||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||
}
|
||
|
||
fun setFexPreset(context: Context, id: String) {
|
||
prefs(context).edit().putString("fexPreset", id).apply()
|
||
runCatching { GameEnvironmentStore.publish(context) }
|
||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||
}
|
||
|
||
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
|
||
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
|
||
|
||
fun setRunSteamAtStartup(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("runSteamAtStartup", on).apply()
|
||
}
|
||
|
||
/**
|
||
* The Games folders earlier builds let the user add (one game per subfolder); read only by
|
||
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
|
||
* even earlier build kept is carried in.
|
||
*/
|
||
fun addedGamesDirs(context: Context): List<String> {
|
||
val p = prefs(context)
|
||
val list = p.getString("addedGamesDirs", null)
|
||
if (list != null) return list.split('\n').filter { it.isNotEmpty() }
|
||
val old = p.getString("addedGamesDir", "") ?: ""
|
||
return if (old.isEmpty()) emptyList() else listOf(old)
|
||
}
|
||
|
||
fun setAddedGamesDirs(context: Context, dirs: List<String>) {
|
||
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
|
||
}
|
||
|
||
/**
|
||
* The Games folders earlier builds had (host path, guest path), still bound into the session so
|
||
* their games keep their paths; written once by GamesFolderMigration.
|
||
*/
|
||
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
|
||
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
|
||
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
|
||
}
|
||
|
||
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
|
||
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
|
||
}
|
||
|
||
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
|
||
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
|
||
|
||
fun setGamesFoldersMigrated(context: Context) {
|
||
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
|
||
}
|
||
|
||
/**
|
||
* Game folders (by path) the user removed from the added games: a scan skips them, so they
|
||
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
|
||
*/
|
||
fun removedAddedGames(context: Context): List<String> =
|
||
(prefs(context).getString("removedAddedGames", "") ?: "").split('\n').filter { it.isNotEmpty() }
|
||
|
||
fun setAddedGameRemoved(context: Context, folderPath: String, removed: Boolean) {
|
||
synchronized(addedGameLock) {
|
||
val now = removedAddedGames(context).filter { it != folderPath } + listOfNotNull(folderPath.takeIf { removed })
|
||
prefs(context).edit().putString("removedAddedGames", now.joinToString("\n")).apply()
|
||
}
|
||
}
|
||
|
||
/** An added game's name as the user set it (in the app, or in Steam's Properties); "" = automatic. */
|
||
fun addedGameName(context: Context, folderPath: String): String = prefs(context).getString("addedName:$folderPath", "") ?: ""
|
||
|
||
fun setAddedGameName(context: Context, folderPath: String, name: String) {
|
||
prefs(context).edit().putString("addedName:$folderPath", name.trim()).apply()
|
||
}
|
||
|
||
/** An added game's Start in, as the session sees it; "" = the exe's own folder. */
|
||
fun addedGameStartIn(context: Context, folderPath: String): String = prefs(context).getString("addedStartIn:$folderPath", "") ?: ""
|
||
|
||
fun setAddedGameStartIn(context: Context, folderPath: String, guestDir: String) {
|
||
prefs(context).edit().putString("addedStartIn:$folderPath", guestDir.trim()).apply()
|
||
}
|
||
|
||
/** An added game's launch options, as Steam takes them ("-dx11", "VAR=1 %command%"). */
|
||
fun addedGameLaunch(context: Context, folderPath: String): String = prefs(context).getString("addedLaunch:$folderPath", "") ?: ""
|
||
|
||
fun setAddedGameLaunch(context: Context, folderPath: String, options: String) {
|
||
prefs(context).edit().putString("addedLaunch:$folderPath", options.trim()).apply()
|
||
}
|
||
|
||
/**
|
||
* The last value of [field] (AppName, StartDir, LaunchOptions) taken over from Steam's own
|
||
* Properties for this game, so the same Steam edit is not taken again over a later app edit.
|
||
*/
|
||
fun addedGameAdopted(context: Context, folderPath: String, field: String): String? =
|
||
prefs(context).getString("addedAdopted:$field:$folderPath", null)
|
||
|
||
fun setAddedGameAdopted(context: Context, folderPath: String, field: String, value: String) {
|
||
prefs(context).edit().putString("addedAdopted:$field:$folderPath", value).apply()
|
||
}
|
||
|
||
/** Where an added game's art for [slot] comes from when the user chose it ("folder", "steam", "sgdb", "file"); "" = automatic. */
|
||
fun addedGameArtSource(context: Context, folderPath: String, slot: String): String =
|
||
prefs(context).getString("addedArt:$slot:$folderPath", "") ?: ""
|
||
|
||
fun setAddedGameArtSource(context: Context, folderPath: String, slot: String, source: String) {
|
||
prefs(context).edit().putString("addedArt:$slot:$folderPath", source).apply()
|
||
}
|
||
|
||
/** Whether added games without art of their own get Steam's store art fetched for them. */
|
||
fun addedGamesArt(context: Context): Boolean = prefs(context).getBoolean("addedGamesArt", true)
|
||
|
||
fun setAddedGamesArt(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("addedGamesArt", on).apply()
|
||
}
|
||
|
||
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
|
||
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
|
||
|
||
private val addedGameLock = Any()
|
||
|
||
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
|
||
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
|
||
}
|
||
|
||
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
|
||
|
||
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
|
||
synchronized(addedGameLock) {
|
||
val unchanged = addedGameExe(context, folderPath) == expected
|
||
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
|
||
unchanged
|
||
}
|
||
|
||
/** The shortcut appid stored for the folder, or null before its first scan. */
|
||
fun addedGameStoredAppId(context: Context, folderPath: String): Long? =
|
||
prefs(context).getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||
|
||
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||
val p = prefs(context)
|
||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
|
||
}
|
||
|
||
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
|
||
fun theme(context: Context): String = prefs(context).getString("theme", "graphite") ?: "graphite"
|
||
|
||
fun setTheme(context: Context, id: String) {
|
||
prefs(context).edit().putString("theme", id).apply()
|
||
}
|
||
|
||
/**
|
||
* A fixed size for the session's display, per mode, or null. When set it replaces both the
|
||
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
|
||
*/
|
||
private fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
|
||
parseResolution(prefs(context).getString("customRes.$mode", null))
|
||
|
||
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
|
||
fun parseResolution(text: String?): Pair<Int, Int>? {
|
||
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
|
||
if (parts.size != 2) return null
|
||
val w = parts[0].toIntOrNull() ?: return null
|
||
val h = parts[1].toIntOrNull() ?: return null
|
||
if (w !in 320..3840 || h !in 240..2160) return null
|
||
return Pair(w and 1.inv(), h and 1.inv())
|
||
}
|
||
|
||
/**
|
||
* HDR10 output for MODE_STEAM / MODE_DESKTOP. Off by default. Honoured only when the panel
|
||
* lists HDR10 (HdrSupport), and decided when the compositor starts, which is once per app
|
||
* process: a change applies after the app is fully closed and opened again.
|
||
*/
|
||
fun hdr(context: Context, mode: String): Boolean = prefs(context).getBoolean("hdr.$mode", false)
|
||
|
||
fun setHdr(context: Context, mode: String, on: Boolean) {
|
||
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
|
||
}
|
||
|
||
/**
|
||
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
|
||
* -r (what the client and its games see as the display's rate), the compositor's buffer release
|
||
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
|
||
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
|
||
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
|
||
* does. Applies next session.
|
||
*/
|
||
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
|
||
|
||
fun setFpsLimit(context: Context, mode: String, fps: Int) {
|
||
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
|
||
}
|
||
|
||
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
|
||
|
||
/** [fpsLimitChoices] labelled in the app's language. */
|
||
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
|
||
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
|
||
|
||
/**
|
||
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
|
||
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
|
||
* when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs
|
||
* are equivalent on Wayland, so saved aliases resolve to one choice.
|
||
*/
|
||
val upscalerChoices = listOf(
|
||
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
|
||
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
|
||
)
|
||
|
||
/** The [upscalerChoices] labels that are words rather than product names. */
|
||
private val upscalerLabels = mapOf(
|
||
0 to R.string.sprefs_upscaler_linear, 2 to R.string.sprefs_upscaler_nearest,
|
||
8 to R.string.sprefs_upscaler_gsr_quality, 6 to R.string.sprefs_upscaler_sharpen,
|
||
)
|
||
|
||
/** [upscalerChoices] labelled in the app's language; the English list stays for the device report. */
|
||
fun upscalerChoices(context: Context): List<Pair<Int, String>> =
|
||
upscalerChoices.map { (mode, label) -> mode to (upscalerLabels[mode]?.let(context::getString) ?: label) }
|
||
|
||
fun canonicalUpscaler(mode: Int): Int = when (mode) {
|
||
1 -> 0
|
||
5 -> 4
|
||
else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
|
||
}
|
||
|
||
fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8
|
||
|
||
fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0))
|
||
|
||
fun setUpscaler(context: Context, mode: Int) {
|
||
prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply()
|
||
}
|
||
|
||
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
|
||
|
||
fun setUpscaleSharpness(context: Context, pct: Int) {
|
||
prefs(context).edit().putInt("upscaleSharpness", pct.coerceIn(0, 100)).apply()
|
||
}
|
||
|
||
// ── Screen effects and texture filtering (the Display page) ─────────────────────────────
|
||
|
||
/** The compositor's post chain as last set; off until the user picks a Look or moves a row. */
|
||
fun screenEffects(context: Context): ScreenEffects {
|
||
val text = prefs(context).getString("screenEffects", null) ?: return ScreenEffects.OFF
|
||
return runCatching { ScreenEffects.decode(JSONObject(text)) }.getOrDefault(ScreenEffects.OFF)
|
||
}
|
||
|
||
fun setScreenEffects(context: Context, effects: ScreenEffects) {
|
||
prefs(context).edit().putString("screenEffects", effects.encode().toString()).apply()
|
||
}
|
||
|
||
/** The key [cardboard] lives under; SessionActivity watches it so a change applies live. */
|
||
const val CARDBOARD_KEY = "cardboard"
|
||
|
||
/** Cardboard mode (gpu/Cardboard) as last set; off by default. */
|
||
fun cardboard(context: Context): Cardboard {
|
||
val text = prefs(context).getString(CARDBOARD_KEY, null) ?: return Cardboard.OFF
|
||
return runCatching { Cardboard.decode(JSONObject(text)) }.getOrDefault(Cardboard.OFF)
|
||
}
|
||
|
||
fun setCardboard(context: Context, cardboard: Cardboard) {
|
||
prefs(context).edit().putString(CARDBOARD_KEY, cardboard.encode().toString()).apply()
|
||
}
|
||
|
||
/** The Cardboard picture (Cardboard.SOURCE_*) to go back to once SteamVR stops; -1 = none (it was
|
||
* not switched for SteamVR). Kept across activity restarts, so a SteamVR session that outlives the
|
||
* activity still hands the picture back. */
|
||
fun cardboardSourceBeforeSteamVr(context: Context): Int = prefs(context).getInt("cardboardSourceBeforeSteamVr", -1)
|
||
|
||
fun setCardboardSourceBeforeSteamVr(context: Context, source: Int) {
|
||
prefs(context).edit().putInt("cardboardSourceBeforeSteamVr", source).apply()
|
||
}
|
||
|
||
/** The prefs file [cardboard] lives in, for a change listener. */
|
||
fun sessionPrefs(context: Context): android.content.SharedPreferences = prefs(context)
|
||
|
||
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
|
||
|
||
/** [textureAnisotropyChoices] labelled in the app's language. */
|
||
fun textureAnisotropyChoices(context: Context): List<Pair<Int, String>> =
|
||
textureAnisotropyChoices.map { (value, label) -> value to if (value == 0) context.getString(R.string.frame_gen_off) else label }
|
||
|
||
val textureLodBiasChoices = TextureFiltering.LOD_BIAS.map {
|
||
it to when (it) {
|
||
TextureFiltering.LOD_BIAS_OFF -> "Off"
|
||
TextureFiltering.LOD_BIAS_AUTO -> "Auto (match scaling)"
|
||
else -> it
|
||
}
|
||
}
|
||
|
||
/** [textureLodBiasChoices] labelled in the app's language. */
|
||
fun textureLodBiasChoices(context: Context): List<Pair<String, String>> = textureLodBiasChoices.map { (value, label) ->
|
||
value to when (value) {
|
||
TextureFiltering.LOD_BIAS_OFF -> context.getString(R.string.frame_gen_off)
|
||
TextureFiltering.LOD_BIAS_AUTO -> context.getString(R.string.sprefs_lod_bias_auto)
|
||
else -> label
|
||
}
|
||
}
|
||
|
||
/** Anisotropic filtering forced on DirectX 9-11 games (core/TextureFiltering); 0 = the game's own. */
|
||
fun textureAnisotropy(context: Context): Int =
|
||
prefs(context).getInt("textureAnisotropy", 0).takeIf { it in TextureFiltering.ANISOTROPY } ?: 0
|
||
|
||
fun setTextureAnisotropy(context: Context, value: Int) {
|
||
prefs(context).edit().putInt("textureAnisotropy", value).apply()
|
||
publishGameEnvironment(context)
|
||
}
|
||
|
||
/** Texture sharpness: the mip LOD bias choice (TextureFiltering.LOD_BIAS), "0" = the game's own. */
|
||
fun textureLodBias(context: Context): String =
|
||
prefs(context).getString("textureLodBias", null)?.takeIf { it in TextureFiltering.LOD_BIAS } ?: TextureFiltering.LOD_BIAS_OFF
|
||
|
||
fun setTextureLodBias(context: Context, choice: String) {
|
||
prefs(context).edit().putString("textureLodBias", choice).apply()
|
||
publishGameEnvironment(context)
|
||
}
|
||
|
||
fun gameTextureFiltering(context: Context, scope: String): GameTextureFiltering {
|
||
require(GameEnvironment.validScope(scope))
|
||
return gameTextureFiltering(context)[scope] ?: GameTextureFiltering()
|
||
}
|
||
|
||
fun gameTextureFiltering(context: Context): Map<String, GameTextureFiltering> {
|
||
val json = runCatching {
|
||
JSONObject(prefs(context).getString("gameTextureFiltering", null) ?: "{}")
|
||
}.getOrElse { JSONObject() }
|
||
return buildMap {
|
||
json.keys().forEach { scope ->
|
||
if (!GameEnvironment.validScope(scope)) return@forEach
|
||
val entry = json.optJSONObject(scope) ?: return@forEach
|
||
val anisotropy = entry.optInt("anisotropy").takeIf {
|
||
entry.has("anisotropy") && it in TextureFiltering.ANISOTROPY
|
||
}
|
||
val lodBias = entry.optString("lodBias").takeIf {
|
||
entry.has("lodBias") && it in TextureFiltering.LOD_BIAS
|
||
}
|
||
if (anisotropy != null || lodBias != null) {
|
||
put(scope, GameTextureFiltering(anisotropy, lodBias))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fun setGameTextureAnisotropy(context: Context, scope: String, value: Int?) {
|
||
require(value == null || value in TextureFiltering.ANISOTROPY)
|
||
updateGameTextureFiltering(context, scope) { it.copy(anisotropy = value) }
|
||
}
|
||
|
||
fun setGameTextureLodBias(context: Context, scope: String, choice: String?) {
|
||
require(choice == null || choice in TextureFiltering.LOD_BIAS)
|
||
updateGameTextureFiltering(context, scope) { it.copy(lodBias = choice) }
|
||
}
|
||
|
||
fun clearGameTextureFiltering(context: Context, scope: String) {
|
||
require(GameEnvironment.validScope(scope))
|
||
val choices = gameTextureFiltering(context).toMutableMap()
|
||
if (choices.remove(scope) == null) return
|
||
saveGameTextureFiltering(context, choices)
|
||
publishGameEnvironment(context)
|
||
}
|
||
|
||
private fun updateGameTextureFiltering(
|
||
context: Context,
|
||
scope: String,
|
||
change: (GameTextureFiltering) -> GameTextureFiltering,
|
||
) {
|
||
require(GameEnvironment.validScope(scope))
|
||
val choices = gameTextureFiltering(context).toMutableMap()
|
||
val next = change(choices[scope] ?: GameTextureFiltering())
|
||
if (next.anisotropy == null && next.lodBias == null) choices.remove(scope) else choices[scope] = next
|
||
saveGameTextureFiltering(context, choices)
|
||
publishGameEnvironment(context)
|
||
}
|
||
|
||
private fun saveGameTextureFiltering(context: Context, choices: Map<String, GameTextureFiltering>) {
|
||
val json = JSONObject()
|
||
choices.forEach { (id, choice) ->
|
||
json.put(id, JSONObject().apply {
|
||
choice.anisotropy?.let { put("anisotropy", it) }
|
||
choice.lodBias?.let { put("lodBias", it) }
|
||
})
|
||
}
|
||
prefs(context).edit().putString("gameTextureFiltering", json.toString()).apply()
|
||
}
|
||
|
||
/** Hands the change to the next game launch (GameEnvironmentStore); the running game keeps its own. */
|
||
private fun publishGameEnvironment(context: Context) {
|
||
runCatching { GameEnvironmentStore.publish(context) }
|
||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||
}
|
||
|
||
/**
|
||
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
|
||
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
|
||
*/
|
||
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
|
||
|
||
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
|
||
|
||
fun setPipAutoEnter(context: Context, enabled: Boolean) {
|
||
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
|
||
}
|
||
|
||
/** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */
|
||
fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false)
|
||
|
||
fun setUnfoldedControls(context: Context, enabled: Boolean) {
|
||
prefs(context).edit().putBoolean("unfoldedControls", enabled).apply()
|
||
}
|
||
|
||
const val SURFACE_STICK = "stick"
|
||
const val SURFACE_TRACKPAD = "trackpad"
|
||
|
||
/** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad.
|
||
* Left a stick and right a trackpad until changed, as a Deck is mostly played. */
|
||
fun gamepadSurface(context: Context, right: Boolean): String =
|
||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||
|
||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||
}
|
||
|
||
fun suspendPolicy(context: Context, mode: String): String =
|
||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
|
||
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
|
||
// Direct games share Steam's settings but have no Steam client to prepare.
|
||
?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it }
|
||
?: SUSPEND_MANUAL
|
||
|
||
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
|
||
val normalized = policy.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_MANUAL, SUSPEND_NEVER) ||
|
||
(it == SUSPEND_NATIVE && mode == SessionService.MODE_STEAM) }
|
||
?: SUSPEND_MANUAL
|
||
val prefs = prefs(context)
|
||
val key = "suspendPolicy.${prefMode(mode)}"
|
||
prefs.edit().apply {
|
||
if (mode == SessionService.MODE_STEAM && prefs.getString(key, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, true)
|
||
}
|
||
putString(key, normalized)
|
||
}.apply()
|
||
}
|
||
|
||
fun steamDownloadsInBackground(context: Context): Boolean {
|
||
val prefs = prefs(context)
|
||
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
|
||
prefs.getString("suspendPolicy.${SessionService.MODE_STEAM}", null) == LEGACY_SUSPEND_DOWNLOADS
|
||
}
|
||
|
||
fun setSteamDownloadsInBackground(context: Context, enabled: Boolean) {
|
||
val prefs = prefs(context)
|
||
val policyKey = "suspendPolicy.${SessionService.MODE_STEAM}"
|
||
prefs.edit().apply {
|
||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, enabled)
|
||
if (prefs.getString(policyKey, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||
putString(policyKey, SUSPEND_AUTO)
|
||
}
|
||
}.apply()
|
||
}
|
||
|
||
/** Steam only: the client's memory goes to the device's zram while a game runs (ZramSupport). */
|
||
fun zramCompression(context: Context): Boolean {
|
||
val prefs = prefs(context)
|
||
return if (prefs.contains("zramCompression")) prefs.getBoolean("zramCompression", false) else ZramSupport.recommended(context)
|
||
}
|
||
|
||
fun setZramCompression(context: Context, on: Boolean) {
|
||
prefs(context).edit().putBoolean("zramCompression", on).apply()
|
||
}
|
||
|
||
// ── Game storage ────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and
|
||
* registered with the client, which then asks where to install every game and shows both
|
||
* on its Storage page. "" = automatic: the SD card when one is in the phone (the default,
|
||
* so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal
|
||
* only; otherwise the folder chosen.
|
||
*/
|
||
fun gameStorage(context: Context): String = prefs(context).getString("gameStorage", "") ?: ""
|
||
|
||
const val GAME_STORAGE_OFF = "off"
|
||
|
||
fun gameStorageLabel(context: Context): String = prefs(context).getString("gameStorageLabel", "SD Card") ?: "SD Card"
|
||
|
||
fun setGameStorage(context: Context, path: String, label: String) {
|
||
prefs(context).edit().putString("gameStorage", path).putString("gameStorageLabel", label).apply()
|
||
}
|
||
}
|