From 0c819b48dbb46ef58162d0748bf91677724aaa88 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:52:19 -0400 Subject: [PATCH] fix(ui): preserve virtual display and CUDA path state Keep blank Polaris client display settings from overriding game virtual-display recommendations, normalize virtual-display aliases in the Polaris sync sheet, and treat CUDA target-device session reports as GPU-path truth when residency is omitted. --- .../nova/api/PolarisGameLaunchModeAdapter.kt | 23 +++++++++----- .../com/papi/nova/api/PolarisSessionStatus.kt | 8 ++++- .../papi/nova/ui/NovaPolarisSyncUiState.kt | 22 ++++++++++---- .../nova/api/PolarisApiClientParsingTest.kt | 17 +++++++++++ .../papi/nova/ui/NovaGameDetailUiStateTest.kt | 20 +++++++++++++ .../com/papi/nova/ui/NovaHudUiStateTest.kt | 30 +++++++++++++++++++ .../nova/ui/NovaPolarisSyncUiStateTest.kt | 28 +++++++++++++++++ 7 files changed, 134 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameLaunchModeAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameLaunchModeAdapter.kt index 1119e617..5468c586 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisGameLaunchModeAdapter.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisGameLaunchModeAdapter.kt @@ -8,11 +8,12 @@ fun PolarisGame.resolveLaunchModeChoice(defaultToVirtualDisplay: Boolean, client val virtualAvailable = modeAvailability(clientSettings, "virtual_display") val headlessAllowed = (contract?.allows("headless") ?: true) && headlessAvailable != false val virtualDisplayAllowed = (contract?.allows("virtual_display") ?: true) && virtualAvailable != false - val hostDefaultMode = PolarisGame.resolveLaunchMode( - clientSettings?.desired?.streamDisplayMode?.takeIf { it.isNotBlank() } ?: clientSettings?.effective?.streamDisplayMode ?: "", - headlessAllowed, - virtualDisplayAllowed - ) + val hostRequestedMode = clientSettings?.desired?.streamDisplayMode?.takeIf { it.isNotBlank() } + ?: clientSettings?.effective?.streamDisplayMode?.takeIf { it.isNotBlank() } + ?: "" + val hostDefaultMode = hostRequestedMode.takeIf { it.isNotBlank() }?.let { + PolarisGame.resolveLaunchMode(it, headlessAllowed, virtualDisplayAllowed) + } ?: "" val fallbackMode = if (defaultToVirtualDisplay && virtualDisplayAllowed) "virtual_display" else "headless" val preferredMode = PolarisGame.resolveLaunchMode(contract?.preferredMode?.takeIf { it.isNotBlank() } ?: fallbackMode, headlessAllowed, virtualDisplayAllowed) val recommendedMode = PolarisGame.resolveLaunchMode(hostDefaultMode.takeIf { it.isNotBlank() } ?: contract?.recommendedMode?.takeIf { it.isNotBlank() } ?: preferredMode, headlessAllowed, virtualDisplayAllowed) @@ -35,16 +36,22 @@ private val VIRTUAL_DISPLAY_MODE_ALIASES = setOf("virtual_display", PolarisClien private fun modeAvailability(clientSettings: PolarisClientSettings?, mode: String): Boolean? { val modes = clientSettings?.capabilities?.modes ?: return null - val aliases = aliasesForMode(mode) - val matches = modes.filter { it.value in aliases } + val matches = matchingModes(modes, mode) if (matches.isEmpty()) return null return matches.any { it.available } } private fun modeUnavailableReason(clientSettings: PolarisClientSettings?, mode: String): String { val modes = clientSettings?.capabilities?.modes ?: return "" + return matchingModes(modes, mode).firstOrNull { !it.available }?.reason.orEmpty() +} + +private fun matchingModes(modes: List, mode: String): List { val aliases = aliasesForMode(mode) - return modes.firstOrNull { it.value in aliases && !it.available }?.reason.orEmpty() + val normalizedMode = PolarisGame.normalizeLaunchMode(mode) + return modes.filter { option -> + option.value in aliases || PolarisGame.normalizeLaunchMode(option.value) == normalizedMode + } } private fun aliasesForMode(mode: String): Set { diff --git a/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt b/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt index 082d90f7..24a719c3 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt @@ -264,7 +264,8 @@ data class PolarisSessionStatus( val isShuttingDown get() = shutdownRequested || normalizedState == "tearing_down" val isResumable get() = !isShuttingDown && gameId > 0 && (isSessionAlive || isPausedForResume) val isTenBitActive get() = dynamicRange > 0 || encoder.targetFormat.equals("p010", ignoreCase = true) - val isGpuPath get() = encoder.targetResidency.equals("gpu", ignoreCase = true) + val isGpuPath get() = encoder.targetResidency.equals("gpu", ignoreCase = true) || + (encoder.targetResidency.isBlank() && encoder.targetDevice.isCudaGpuTarget) val isHeadlessMode get() = displayMode.effectiveHeadless val isVirtualDisplayMode get() = displayMode.virtualDisplay val sessionModeLabel get() = when { @@ -277,6 +278,11 @@ data class PolarisSessionStatus( val hasExplicitDisplayModeChoice get() = displayMode.explicitChoice val canAdjustHostTuning get() = controls.hostTuningAllowed || (ownedByClient && !isViewer) val canQuit get() = controls.quitAllowed || (ownedByClient && !isViewer) + + private val String.isCudaGpuTarget: Boolean + get() = equals("cuda", ignoreCase = true) || + equals("gpu", ignoreCase = true) || + equals("nvidia", ignoreCase = true) val isClientPresentationSynced get() = clientPresentation.status.equals("synced", ignoreCase = true) val hasOptimizerSync get() = syncStatus.available val optimizationSourceLabel get() = when { diff --git a/app/src/main/java/com/papi/nova/ui/NovaPolarisSyncUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaPolarisSyncUiState.kt index e185fd53..08027895 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaPolarisSyncUiState.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaPolarisSyncUiState.kt @@ -54,11 +54,14 @@ object NovaPolarisSyncUiStateMapper { unsetLabel: String = "Unset" ): NovaPolarisSyncUiState { val fallback = if (settingsUnavailable) unavailableLabel else loadingLabel - val selectedMode = settings?.desired?.streamDisplayMode?.takeIf { it.isNotBlank() } - ?: settings?.effective?.streamDisplayMode.orEmpty() + val selectedMode = canonicalDisplayMode( + settings?.desired?.streamDisplayMode?.takeIf { it.isNotBlank() } + ?: settings?.effective?.streamDisplayMode.orEmpty() + ) val availableModes = settings?.capabilities?.modes ?.takeIf { it.isNotEmpty() } - ?.associateBy { it.value } + ?.groupBy { canonicalDisplayMode(it.value) } + ?.mapValues { (_, modes) -> modes.any { it.available } } val profileState = PolarisProfileSync.compare(novaDisplayMode, novaBitrateKbps, settings) val hasPolarisProfile = settings?.let { PolarisProfileSync.polarisOverrideProfile(it) } != null val aiAvailable = settings?.capabilities?.aiAutoQualityControl == true || @@ -81,10 +84,11 @@ object NovaPolarisSyncUiStateMapper { desiredModeLabel = settings?.desiredModeLabel?.ifBlank { unsetLabel } ?: fallback, effectiveModeLabel = settings?.effectiveModeLabel?.ifBlank { unsetLabel } ?: fallback, modes = DISPLAY_MODES.map { mode -> - val available = availableModes?.get(mode)?.available ?: true + val canonicalMode = canonicalDisplayMode(mode) + val available = availableModes?.get(canonicalMode) ?: true NovaPolarisModeUiState( mode = mode, - selected = selectedMode == mode, + selected = selectedMode == canonicalMode, enabled = settings != null && available && !busy ) }, @@ -100,4 +104,12 @@ object NovaPolarisSyncUiStateMapper { autoSyncEnabled = hasServerUuid && settings != null && !busy ) } + + private fun canonicalDisplayMode(mode: String): String { + return when (mode.trim().lowercase()) { + "headless", PolarisClientSettings.MODE_HEADLESS_STREAM, "host_display" -> PolarisClientSettings.MODE_HEADLESS_STREAM + "virtual_display", PolarisClientSettings.MODE_HOST_VIRTUAL_DISPLAY -> PolarisClientSettings.MODE_HOST_VIRTUAL_DISPLAY + else -> mode.trim().lowercase() + } + } } diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt index e80d252b..8c6d0840 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -254,6 +254,23 @@ class PolarisApiClientParsingTest { assertEquals("Black Myth: Wukong", body.getString("game")) } + @Test + fun parseSessionStatus_cudaTargetDeviceImpliesGpuPathWhenResidencyMissing() { + val status = PolarisApiClient.parseSessionStatusResponse( + JSONObject( + "{\"state\":\"streaming\",\"streaming_active\":true," + + "\"display_mode\":{\"label\":\"Virtual Display\",\"selection\":\"virtual_display\"," + + "\"virtual_display\":true,\"effective_headless\":false}," + + "\"capture\":{\"transport\":\"dmabuf\"}," + + "\"encoder\":{\"codec\":\"hevc_nvenc\",\"target_device\":\"cuda\",\"target_format\":\"p010\"}}" + ) + ) + + assertEquals("cuda", status.encoder.targetDevice) + assertTrue(status.isGpuPath) + assertTrue(status.isTenBitActive) + } + @Test fun parseGameResponse_includesLaunchModeContract() { val json = JSONObject( diff --git a/app/src/test/java/com/papi/nova/ui/NovaGameDetailUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaGameDetailUiStateTest.kt index ee898d63..f0725217 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaGameDetailUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaGameDetailUiStateTest.kt @@ -32,6 +32,26 @@ class NovaGameDetailUiStateTest { assertEquals("quality", state.profilePreference) } + @Test + fun virtualRecommendationWinsWhenClientSettingsHaveNoDisplayMode() { + val state = NovaGameDetailUiState.from( + game = game( + launchMode = PolarisGame.LaunchModeContract( + preferredMode = "headless", + recommendedMode = "virtual_display", + allowedModes = listOf("headless", "virtual_display") + ) + ), + defaultToVirtualDisplay = false, + clientSettings = PolarisClientSettings(), + profilePreference = "auto" + ) + + assertEquals("virtual_display", state.playMode) + assertTrue(state.playUsesVirtualDisplay) + assertTrue(state.playEnabled) + } + @Test fun unavailableVirtualDisplayFallsBackToHeadlessAndShowsUnavailableState() { val state = NovaGameDetailUiState.from( diff --git a/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt index 4365effe..55a16cb9 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt @@ -65,6 +65,36 @@ class NovaHudUiStateTest { assertEquals(listOf(55f, 58f, 60f), state.sparklineSamples) } + @Test + fun cudaTargetDeviceKeepsGpuPathInStreamModeLabelWhenResidencyMissing() { + val state = NovaHudUiState.from( + mode = NovaHudMode.DEBUG, + fps = 59.8, + targetFps = 60.0, + latencyMs = 18, + codec = "hevc_nvenc", + bitrateKbps = 20000, + width = 1920, + height = 1080, + status = status( + encoder = PolarisSessionStatus.EncoderStatus( + codec = "hevc_nvenc", + targetDevice = "cuda", + targetResidency = "", + targetFormat = "p010" + ), + capture = PolarisSessionStatus.CaptureStatus( + transport = "dmabuf", + residency = "" + ) + ), + sparklineSamples = emptyList() + ) + + assertTrue(state.streamModeLabel.contains("GPU")) + assertTrue(state.streamModeLabel.contains("10b")) + } + @Test fun hudModesMapCasualPerformanceAndDebugPreferences() { assertEquals(NovaHudMode.MINIMAL, NovaHudMode.fromPreference("minimal")) diff --git a/app/src/test/java/com/papi/nova/ui/NovaPolarisSyncUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaPolarisSyncUiStateTest.kt index 2b34cf78..7679864b 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaPolarisSyncUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaPolarisSyncUiStateTest.kt @@ -55,6 +55,34 @@ class NovaPolarisSyncUiStateTest { assertTrue(state.clearProfileEnabled) } + @Test + fun normalizedVirtualDisplayModeSelectsAndDisablesCanonicalRow() { + val state = NovaPolarisSyncUiStateMapper.build( + settings = PolarisClientSettings( + desired = PolarisClientSettings.Desired(streamDisplayMode = "virtual_display"), + capabilities = PolarisClientSettings.Capabilities( + modes = listOf( + PolarisClientSettings.ModeOption( + value = "virtual_display", + available = false, + reason = "CUDA capture path is disabled" + ) + ) + ) + ), + busy = false, + settingsUnavailable = false, + autoSyncEnabled = false, + hasServerUuid = true, + novaDisplayMode = "1920x1080@60", + novaBitrateKbps = 30000 + ) + + val virtual = state.modes.first { it.mode == PolarisClientSettings.MODE_HOST_VIRTUAL_DISPLAY } + assertTrue(virtual.selected) + assertFalse(virtual.enabled) + } + @Test fun busyStateDisablesAllMutatingActions() { val state = NovaPolarisSyncUiStateMapper.build(