Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package app.gamenative.ui.component

internal fun fpsLimiterSteps(maxFps: Int): List<Int> {
val sanitizedMax = maxFps.coerceAtLeast(5)
val flooredMax = (sanitizedMax / 5) * 5
return buildList {
var value = 5
while (value <= flooredMax) {
add(value)
value += 5
}
if (sanitizedMax != flooredMax) add(sanitizedMax)
}
}

/**
* Returns the index of the step that is the floor of [currentValue] — i.e. the highest
* step that is still ≤ currentValue. Falls back to 0 if currentValue is below the
* first step, so navigation never goes in the wrong direction on a restored value that
* isn't an exact multiple of 5.
*/
internal fun fpsLimiterCurrentIndex(steps: List<Int>, currentValue: Int): Int =
steps.indexOfLast { it <= currentValue }.coerceAtLeast(0)

internal fun fpsLimiterProgress(currentValue: Int, maxFps: Int): Float {
val steps = fpsLimiterSteps(maxFps)
val currentIndex = fpsLimiterCurrentIndex(steps, currentValue)
return if (steps.lastIndex <= 0) 1f else currentIndex.toFloat() / steps.lastIndex.toFloat()
}

internal fun nextFpsLimiterValue(currentValue: Int, maxFps: Int): Int {
val steps = fpsLimiterSteps(maxFps)
val currentIndex = fpsLimiterCurrentIndex(steps, currentValue)
return steps[(currentIndex + 1).coerceAtMost(steps.lastIndex)]
}

internal fun previousFpsLimiterValue(currentValue: Int, maxFps: Int): Int {
val steps = fpsLimiterSteps(maxFps)
val currentIndex = fpsLimiterCurrentIndex(steps, currentValue)
return steps[(currentIndex - 1).coerceAtLeast(0)]
}
58 changes: 57 additions & 1 deletion app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.BorderStroke
Expand Down Expand Up @@ -210,6 +212,9 @@ private fun matchesPerformanceHudPreset(
currentConfig.showGpuUsageGraph == presetConfig.showGpuUsageGraph
}

// fpsLimiterSteps / fpsLimiterCurrentIndex / fpsLimiterProgress /
// nextFpsLimiterValue / previousFpsLimiterValue live in FpsLimiterUtils.kt

@Composable
fun QuickMenu(
isVisible: Boolean,
Expand All @@ -218,7 +223,12 @@ fun QuickMenu(
renderer: GLRenderer? = null,
isPerformanceHudEnabled: Boolean = false,
performanceHudConfig: PerformanceHudConfig = PerformanceHudConfig(),
fpsLimiterEnabled: Boolean = true,
fpsLimiterTarget: Int = 60,
fpsLimiterMax: Int = 60,
onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit = {},
onFpsLimiterEnabledChanged: (Boolean) -> Unit = {},
onFpsLimiterChanged: (Int) -> Unit = {},
hasPhysicalController: Boolean = false,
activeToggleIds: Set<Int> = emptySet(),
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -458,10 +468,15 @@ fun QuickMenu(
PerformanceHudQuickMenuTab(
isPerformanceHudEnabled = isPerformanceHudEnabled,
performanceHudConfig = performanceHudConfig,
fpsLimiterEnabled = fpsLimiterEnabled,
fpsLimiterTarget = fpsLimiterTarget,
fpsLimiterMax = fpsLimiterMax,
onTogglePerformanceHud = {
onItemSelected(QuickMenuAction.PERFORMANCE_HUD)
},
onPerformanceHudConfigChanged = onPerformanceHudConfigChanged,
onFpsLimiterEnabledChanged = onFpsLimiterEnabledChanged,
onFpsLimiterChanged = onFpsLimiterChanged,
scrollState = hudScrollState,
focusRequester = hudItemFocusRequester,
modifier = Modifier.fillMaxSize(),
Expand Down Expand Up @@ -541,8 +556,13 @@ fun QuickMenu(
private fun PerformanceHudQuickMenuTab(
isPerformanceHudEnabled: Boolean,
performanceHudConfig: PerformanceHudConfig,
fpsLimiterEnabled: Boolean,
fpsLimiterTarget: Int,
fpsLimiterMax: Int,
onTogglePerformanceHud: () -> Unit,
onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit,
onFpsLimiterEnabledChanged: (Boolean) -> Unit,
onFpsLimiterChanged: (Int) -> Unit,
scrollState: ScrollState,
focusRequester: FocusRequester? = null,
modifier: Modifier = Modifier,
Expand All @@ -555,13 +575,49 @@ private fun PerformanceHudQuickMenuTab(
.focusGroup(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// ── FPS Limiter (topmost) ────────────────────────────────────────
QuickMenuToggleRow(
title = stringResource(R.string.performance_hud_fps_limiter),
enabled = fpsLimiterEnabled,
onToggle = { onFpsLimiterEnabledChanged(!fpsLimiterEnabled) },
accentColor = accentColor,
focusRequester = focusRequester,
)

AnimatedVisibility(
visible = fpsLimiterEnabled,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column {
Spacer(modifier = Modifier.height(4.dp))
QuickMenuAdjustmentRow(
title = stringResource(R.string.performance_hud_fps_limiter_target),
valueText = stringResource(
R.string.performance_hud_fps_limiter_value,
fpsLimiterTarget,
),
progress = fpsLimiterProgress(fpsLimiterTarget, fpsLimiterMax),
onDecrease = {
onFpsLimiterChanged(previousFpsLimiterValue(fpsLimiterTarget, fpsLimiterMax))
},
onIncrease = {
onFpsLimiterChanged(nextFpsLimiterValue(fpsLimiterTarget, fpsLimiterMax))
},
accentColor = accentColor,
)
}
}

Spacer(modifier = Modifier.height(8.dp))

// ── Performance HUD ──────────────────────────────────────────────
QuickMenuToggleRow(
title = stringResource(R.string.performance_hud),
subtitle = stringResource(R.string.performance_hud_description),
enabled = isPerformanceHudEnabled,
onToggle = onTogglePerformanceHud,
accentColor = accentColor,
focusRequester = focusRequester,
)

Spacer(modifier = Modifier.height(8.dp))
Expand Down
101 changes: 101 additions & 0 deletions app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.hardware.display.DisplayManager
import android.hardware.input.InputManager
import android.view.InputDevice
import androidx.activity.ComponentActivity
Expand All @@ -39,6 +40,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
Expand Down Expand Up @@ -159,6 +161,7 @@ import com.winlator.xserver.ScreenInfo
import com.winlator.xserver.Window
import com.winlator.xserver.WindowManager
import com.winlator.xserver.XServer
import com.winlator.xserver.extensions.PresentExtension
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -181,6 +184,7 @@ import java.util.Arrays
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.path.name
import kotlin.math.roundToInt
import kotlin.text.lowercase
import com.winlator.PrefManager as WinlatorPrefManager

Expand All @@ -194,6 +198,55 @@ private val isExiting = AtomicBoolean(false)
private const val EXIT_PROCESS_TIMEOUT_MS = 30_000L
private const val EXIT_PROCESS_POLL_INTERVAL_MS = 1_000L
private const val EXIT_PROCESS_RESPONSE_TIMEOUT_MS = 2_000L
private const val DEFAULT_FPS_LIMITER_MAX_HZ = 60
private const val DEFAULT_FPS_LIMITER_TARGET_HZ = 60
private const val FPS_LIMITER_ENABLED_EXTRA = "fpsLimiterEnabled"
private const val FPS_LIMITER_TARGET_EXTRA = "fpsLimiterTarget"

private fun parsePositiveFpsLimit(value: String): Int? = value.toIntOrNull()?.takeIf { it > 0 }

private fun hasLegacyDxvkFrameRate(container: Container): Boolean = EnvVars(container.envVars).has("DXVK_FRAME_RATE")

private fun legacyDxvkFrameRate(container: Container): Int? =
parsePositiveFpsLimit(EnvVars(container.envVars).get("DXVK_FRAME_RATE"))

private fun parseBooleanExtra(value: String): Boolean? =
when (value.trim().lowercase(Locale.US)) {
"true" -> true
"false" -> false
else -> null
}

private fun initialFpsLimiterEnabled(container: Container): Boolean {
parseBooleanExtra(container.getExtra(FPS_LIMITER_ENABLED_EXTRA))?.let { return it }
return !hasLegacyDxvkFrameRate(container)
}

private fun initialFpsLimiterTarget(container: Container): Int =
parsePositiveFpsLimit(container.getExtra(FPS_LIMITER_TARGET_EXTRA))
?: legacyDxvkFrameRate(container)
?: DEFAULT_FPS_LIMITER_TARGET_HZ

private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int {
val display = attachedView?.display
?: context.display
?: ContextCompat.getSystemService(context, DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)

val refreshRate = when {
display == null -> DEFAULT_FPS_LIMITER_MAX_HZ.toFloat()
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
val supportedMax = display.supportedModes.maxOfOrNull { it.refreshRate } ?: display.refreshRate
if (supportedMax.isFinite() && supportedMax > 0f) supportedMax else display.refreshRate
}
else -> display.refreshRate
}

return refreshRate
.takeIf { it.isFinite() && it > 0f }
?.roundToInt()
?.coerceAtLeast(5)
?: DEFAULT_FPS_LIMITER_MAX_HZ
}

private data class XServerViewReleaseBinding(
val xServerView: XServerView,
Expand Down Expand Up @@ -379,6 +432,15 @@ fun XServerScreen(
var hasInternalTouchpad by remember { mutableStateOf(false) }
var hasUpdatedScreenGamepad by remember { mutableStateOf(false) }
var isPerformanceHudEnabled by remember { mutableStateOf(PrefManager.showFps) }
var detectedMaxRefreshRateHz by remember { mutableIntStateOf(detectMaxRefreshRateHz(context, null)) }
var fpsLimiterEnabled by rememberSaveable(container.id) { mutableStateOf(initialFpsLimiterEnabled(container)) }
var fpsLimiterTarget by rememberSaveable(container.id) { mutableIntStateOf(initialFpsLimiterTarget(container)) }

fun persistFpsLimiterState() {
container.putExtra(FPS_LIMITER_ENABLED_EXTRA, fpsLimiterEnabled)
container.putExtra(FPS_LIMITER_TARGET_EXTRA, fpsLimiterTarget)
container.saveData()
}

fun loadPerformanceHudConfig(): PerformanceHudConfig {
return PerformanceHudConfig(
Expand Down Expand Up @@ -441,6 +503,39 @@ fun XServerScreen(
performanceHudView?.setConfig(config)
}

fun applyFpsLimiterToEngines(limit: Int) {
xServerView?.setFrameRateLimit(limit)
xServerView?.getxServer()
?.getExtension<PresentExtension>(PresentExtension.MAJOR_OPCODE.toInt())
?.setFrameRateLimit(limit)
}

fun applyFpsLimiterEnabled(enabled: Boolean) {
fpsLimiterEnabled = enabled
applyFpsLimiterToEngines(if (enabled) fpsLimiterTarget else 0)
persistFpsLimiterState()
}

fun applyFpsLimiterTarget(target: Int) {
val sanitized = target.coerceAtLeast(5).coerceAtMost(detectedMaxRefreshRateHz)
fpsLimiterTarget = sanitized
if (fpsLimiterEnabled) {
applyFpsLimiterToEngines(sanitized)
}
persistFpsLimiterState()
}

LaunchedEffect(xServerView) {
val detectedMax = detectMaxRefreshRateHz(context, xServerView)
detectedMaxRefreshRateHz = detectedMax
val clampedTarget = fpsLimiterTarget.coerceAtMost(detectedMax).coerceAtLeast(5)
if (clampedTarget != fpsLimiterTarget) {
fpsLimiterTarget = clampedTarget
}
val appliedLimit = if (fpsLimiterEnabled) clampedTarget else 0
applyFpsLimiterToEngines(appliedLimit)
}

fun restorePerformanceHudPosition() {
val host = performanceHudHost ?: return
val hud = performanceHudView ?: return
Expand Down Expand Up @@ -1360,6 +1455,7 @@ fun XServerScreen(
xServerToUse,
).apply {
xServerView = this
setFrameRateLimit(if (fpsLimiterEnabled) fpsLimiterTarget else 0)
val renderer = this.renderer
renderer.isCursorVisible = false
getxServer().renderer = renderer
Expand Down Expand Up @@ -2022,7 +2118,12 @@ fun XServerScreen(
renderer = xServerView?.renderer,
isPerformanceHudEnabled = isPerformanceHudEnabled,
performanceHudConfig = performanceHudConfig,
fpsLimiterEnabled = fpsLimiterEnabled,
fpsLimiterTarget = fpsLimiterTarget,
fpsLimiterMax = detectedMaxRefreshRateHz,
onPerformanceHudConfigChanged = ::applyPerformanceHudConfig,
onFpsLimiterEnabledChanged = ::applyFpsLimiterEnabled,
onFpsLimiterChanged = ::applyFpsLimiterTarget,
hasPhysicalController = hasPhysicalController,
activeToggleIds = buildSet {
if (areControlsVisible) add(QuickMenuAction.INPUT_CONTROLS)
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/winlator/container/Container.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public enum XrControllerMapping {
public static final String EXTERNAL_DISPLAY_MODE_HYBRID = "hybrid";
public static final String DEFAULT_EXTERNAL_DISPLAY_MODE = EXTERNAL_DISPLAY_MODE_OFF;

public static final String DEFAULT_ENV_VARS = "WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact,deck_emu MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform,deck_emu DXVK_FRAME_RATE=60 VKD3D_SHADER_MODEL=6_0 PULSE_LATENCY_MSEC=144";
public static final String DEFAULT_ENV_VARS = "WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact,deck_emu MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform,deck_emu VKD3D_SHADER_MODEL=6_0 PULSE_LATENCY_MSEC=144";
public static final String DEFAULT_SCREEN_SIZE = "1280x720";
public static final String DEFAULT_GRAPHICS_DRIVER = DefaultVersion.DEFAULT_GRAPHICS_DRIVER;
public static final String DEFAULT_AUDIO_DRIVER = "pulseaudio";
Expand Down
Loading
Loading