From b1d70f1d3514ac3d9bedd6e030caf701083489f5 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:51:22 -0400 Subject: [PATCH] feat(play-setup): make High FPS tuning binding via one composed launch blob The Tuning row's High FPS option used to be only a request string on the /optimize query, which the host was free to decline, and often did: a game holding a Recovery profile launched at 30 FPS while the row still read High FPS. It now pins the stream to the Settings frame rate. The pin rides in a composed launch blob that sets safe_target_fps_relaxed, the release field the resolver already honors, so overriding a recovery hold is an explicit, visible act: the Tuning caption states the pin and exactly what the host would have run, the row carries the override accent, and the launch button says the pinned rate instead of promising a recovery launch it will not perform. The new NovaLaunchStreamOverride.compose replaces the display planner's buildLaunchOptimizationOverride, which rebuilt the blob from scratch and silently dropped the stability block, so picking a resolution discarded the recovery clamp as a side effect. Composition deep-copies the host blob instead: a resolution pick pins width and height and leaves the fps clamp standing, and only the fps pin releases it. The resolution row now shows width x height alone, since the trailing rate was the host's plan rather than that row's decision. Paths that bypass Play Setup compose the same way: Game.kt's self-queried optimization fallback applies the pin for Continue Playing launches, and ShortcutTrampoline now honors the per-game tuning preference instead of a hardcoded "auto" before composing. The launch-wait guard in attemptLaunch is read from the raw blob now, because a pick or pin makes the composed blob non-null while the desktop-Steam answer is still on the wire. Two new StreamSyncManagerTest cases pin the resolver ordering the design rests on: a paired override is still clamped by a confirmed recovery, and the relaxed flag on the same blob is what releases it. --- app/src/main/java/com/papi/nova/Game.kt | 12 +- .../java/com/papi/nova/ShortcutTrampoline.kt | 21 +++- .../nova/ui/NovaDisplayResolutionPlanner.kt | 21 ++-- .../papi/nova/ui/NovaGameDetailActivity.kt | 64 +++++++++-- .../papi/nova/ui/NovaLaunchStreamOverride.kt | 86 ++++++++++++++ app/src/main/res/values/strings.xml | 5 +- .../nova/manager/StreamSyncManagerTest.kt | 46 ++++++++ .../ui/NovaDisplayResolutionPlannerTest.kt | 26 ++--- .../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 12 +- .../nova/ui/NovaLaunchStreamOverrideTest.kt | 106 ++++++++++++++++++ 10 files changed, 346 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/papi/nova/ui/NovaLaunchStreamOverride.kt create mode 100644 app/src/test/java/com/papi/nova/ui/NovaLaunchStreamOverrideTest.kt diff --git a/app/src/main/java/com/papi/nova/Game.kt b/app/src/main/java/com/papi/nova/Game.kt index 571f2fc5c..f811875e0 100644 --- a/app/src/main/java/com/papi/nova/Game.kt +++ b/app/src/main/java/com/papi/nova/Game.kt @@ -49,6 +49,7 @@ import com.papi.nova.ui.NovaHudSessionSummaryLog import com.papi.nova.ui.NovaCompanionCommandDeckState import com.papi.nova.ui.NovaHudMode import com.papi.nova.ui.NovaHudUiState +import com.papi.nova.ui.NovaLaunchStreamOverride import com.papi.nova.ui.NovaSnackbar import com.papi.nova.ui.NovaThemeManager import com.papi.nova.ui.NovaSheetChrome @@ -2390,7 +2391,16 @@ if (optimizationResult != null) LimeLog.info(("Nova: Launch optimization loaded source=" + optimizationResult.optString("source", "unknown") + " mode=" + optimizationResult.optString("display_mode", ""))) } -return optimizationResult +// Launches that bypass the detail screen (Continue Playing, shortcuts that lost their +// preflight) arrive here with the raw host blob, so the High FPS pin has to be composed +// on this path too or Tuning = High FPS would only bind when Play Setup was opened. +return NovaLaunchStreamOverride.compose( +optimizationResult, +null, +NovaLaunchStreamOverride.highFpsPin(launchProfilePreference, prefConfig.fps), +prefConfig.width, +prefConfig.height, +prefConfig.fps.toInt()) } private fun getMaxSupportedRefreshRate(display:Display?):Float { diff --git a/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt b/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt index 07164c815..671f14963 100644 --- a/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt +++ b/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt @@ -22,6 +22,8 @@ import com.papi.nova.nvstream.http.NvHTTP import com.papi.nova.nvstream.http.PairingManager import com.papi.nova.nvstream.wol.WakeOnLanSender import com.papi.nova.preferences.PreferenceConfiguration +import com.papi.nova.ui.AutoQualityProfilePreferences +import com.papi.nova.ui.NovaLaunchStreamOverride import com.papi.nova.ui.NovaThemeManager import com.papi.nova.utils.CacheHelper import com.papi.nova.utils.DeviceUtils @@ -679,16 +681,28 @@ class ShortcutTrampoline : NovaActivity() { val clientSettings = apiClient.getClientSettings() syncShortcutLaunchPreflightSettings(apiClient, withVirtualDisplay, clientSettings) + // The per-game Tuning choice, not a fixed "auto": a game pinned to High FPS + // in Play Setup must launch pinned from a home-screen shortcut too. + val profilePreference = AutoQualityProfilePreferences.load(this, polarisGame.name) val optimization = apiClient.getOptimization( DeviceUtils.getModel(), polarisGame.name, - SHORTCUT_PROFILE_PREFERENCE, + profilePreference, mode = PolarisStreamDisplayMode.preflightModeForLaunch(withVirtualDisplay, clientSettings), ) + val preferences = PreferenceConfiguration.readPreferences(this) + val composed = NovaLaunchStreamOverride.compose( + optimization, + null, + NovaLaunchStreamOverride.highFpsPin(profilePreference, preferences.fps), + preferences.width, + preferences.height, + preferences.fps.toInt(), + ) launchPlan.copy( - profilePreference = SHORTCUT_PROFILE_PREFERENCE, - launchOptimizationJson = optimization?.toString(), + profilePreference = profilePreference, + launchOptimizationJson = composed?.toString(), ) } catch (e: Exception) { LimeLog.warning("Nova: Shortcut launch Polaris preflight failed: ${e.message}") @@ -825,7 +839,6 @@ class ShortcutTrampoline : NovaActivity() { companion object { private const val MAX_ART_FILE_CHARS = 64 * 1024 - private const val SHORTCUT_PROFILE_PREFERENCE = "auto" private const val TAG = "ShortcutTrampoline" } } diff --git a/app/src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt b/app/src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt index bba2a0d3a..25de3967f 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt @@ -2,7 +2,6 @@ package com.papi.nova.ui import com.papi.nova.api.PolarisSessionStatus import com.papi.nova.shared.polaris.model.PolarisGame -import org.json.JSONObject import java.util.Locale import kotlin.math.roundToInt @@ -61,17 +60,15 @@ data class NovaDisplayResolutionPlanner( ) } - fun buildLaunchOptimizationOverride(choice: NovaDisplayResolutionChoice, source: String): JSONObject { - return JSONObject().apply { - put("source", source) - put("confidence", "high") - put("display_mode", choice.targetMode) - put("paired_profile_applied", true) - put("normalization_reason", "display_resolution_planner") - put("preference", "auto") - put("preference_applied", true) - put("display_planner_choice", choice.id) - } + /** + * The width x height half of a planner target mode. The trailing rate is the + * host's own plan for that mode, not a decision this row makes -- the frame + * rate is owned by Tuning and the launch composer -- so the row's value must + * not read as one. + */ + fun resolutionLabel(targetMode: String): String { + val parts = targetMode.trim().split('x', 'X') + return if (parts.size == 3) "${parts[0]}x${parts[1]}" else targetMode } private fun plannerTitle(choice: PolarisGame.DisplayPlannerChoice, recommendedId: String): String { diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index bae7d0f78..0fff572de 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -117,6 +117,7 @@ import org.json.JSONObject import java.util.Locale import kotlin.math.abs import kotlin.math.round +import kotlin.math.roundToInt /** @@ -399,12 +400,23 @@ class NovaGameDetailActivity : NovaActivity() { var pendingLaunch by mutableStateOf(false) /** - * The blob this launch would go out with: the resolution chosen here if there is - * one, otherwise whatever the host last planned. + * The blob this launch would go out with: the host's plan, composed with the + * resolution chosen here and the High FPS pin, when either exists. Composed + * over the host blob rather than replacing it, so a resolution pick no longer + * silently discards the recovery clamp -- only the explicit fps pin releases + * it. The fps that launches must always be re-derivable from this blob. */ - fun launchOptimization(): JSONObject? = chosenResolution - ?.let { NovaDisplayResolutionPlanner.buildLaunchOptimizationOverride(it, "nova_display_planner") } - ?: optimizationState.rawOptimization + fun launchOptimization(): JSONObject? { + val preferences = PreferenceConfiguration.readPreferences(this@NovaGameDetailActivity) + return NovaLaunchStreamOverride.compose( + raw = optimizationState.rawOptimization, + resolution = chosenResolution, + fpsOverride = NovaLaunchStreamOverride.highFpsPin(profilePreference, preferences.fps), + fallbackWidth = preferences.width, + fallbackHeight = preferences.height, + fallbackFps = preferences.fps.toInt(), + ) + } fun refreshUiState(preference: String = profilePreference) { uiState = buildUiState(currentGame, preference) @@ -541,7 +553,10 @@ class NovaGameDetailActivity : NovaActivity() { fun attemptLaunch() { if (!uiState.playEnabled) return val optimization = launchOptimization() - if (optimization == null && optimizationState.preflightInFlight) { + // Guarded on the RAW blob: a pick or an fps pin makes the composed blob + // non-null even while the preflight that arms the desktop-Steam guard is + // still on the wire, and a launch in that window must wait either way. + if (optimizationState.rawOptimization == null && optimizationState.preflightInFlight) { pendingLaunch = true // Whatever is waiting to settle is what this launch is waiting on, so run // it now instead of holding the press for a delay that exists to absorb @@ -795,6 +810,11 @@ class NovaGameDetailActivity : NovaActivity() { */ fun buildPlaySetupRows(): List { val rows = mutableListOf() + val preferences = PreferenceConfiguration.readPreferences(this@NovaGameDetailActivity) + val fpsPin = NovaLaunchStreamOverride.highFpsPin(profilePreference, preferences.fps) + val autoSafeFps = StreamSyncManager + .resolveAutoSafeTargetFps(preferences.fps, optimizationState.rawOptimization) + .roundToInt() val modeOptions = buildList { if (uiState.headlessAllowed) { @@ -845,7 +865,7 @@ class NovaGameDetailActivity : NovaActivity() { } else { getString(R.string.nova_play_setup_resolution_caption) }, - value = effective?.targetMode.orEmpty(), + value = NovaDisplayResolutionPlanner.resolutionLabel(effective?.targetMode.orEmpty()), stripTitle = getString(R.string.nova_play_setup_strip_resolution), options = planner.visibleChoices.map { choice -> NovaPlaySetupOption( @@ -864,7 +884,15 @@ class NovaGameDetailActivity : NovaActivity() { rows += NovaPlaySetupRowState( row = NovaPlaySetupRow.TUNING, label = getString(R.string.nova_play_setup_tuning), - caption = getString(R.string.nova_game_detail_profile_caption), + // High FPS is binding, so the caption states the pin -- and, when the + // host is holding a recovery target below it, exactly what is being + // overridden. The other preferences keep the host in control. + caption = when { + fpsPin != null && autoSafeFps in 1 until fpsPin -> + getString(R.string.nova_play_setup_tuning_pins_over_hold, fpsPin, autoSafeFps) + fpsPin != null -> getString(R.string.nova_play_setup_tuning_pins, fpsPin) + else -> getString(R.string.nova_game_detail_profile_caption) + }, value = getString(AutoQualityProfilePreferences.shortLabelRes(profilePreference)), stripTitle = getString(R.string.nova_play_setup_strip_tuning), options = AutoQualityProfilePreferences.values().map { value -> @@ -877,6 +905,7 @@ class NovaGameDetailActivity : NovaActivity() { onSelect = { selectProfilePreference(value) }, ) }, + overridden = fpsPin != null, ) if (uiState.showSteamLaunchMode) { @@ -1066,10 +1095,21 @@ class NovaGameDetailActivity : NovaActivity() { } else if (optimizationState.reviewRequired) { getString(R.string.nova_library_review_and_launch) } else { - optimizationState.profileSummary - ?.primaryLaunchLabel - ?.takeIf { it.isNotBlank() } - ?: primaryPlayLabel(uiState) + // The summary's label states the host's plan; a High FPS pin + // outranks that plan, so the button must state the pin instead + // of promising a recovery launch it will not perform. + val fpsPin = NovaLaunchStreamOverride.highFpsPin( + profilePreference, + PreferenceConfiguration.readPreferences(this@NovaGameDetailActivity).fps + ) + if (fpsPin != null) { + getString(R.string.nova_play_setup_launch_pinned_fps, fpsPin) + } else { + optimizationState.profileSummary + ?.primaryLaunchLabel + ?.takeIf { it.isNotBlank() } + ?: primaryPlayLabel(uiState) + } }, launchModeTitle = getString(R.string.nova_library_launch_mode_title), headlessModeLabel = modeBadgeLabel(PolarisGame.MODE_HEADLESS_STREAM), diff --git a/app/src/main/java/com/papi/nova/ui/NovaLaunchStreamOverride.kt b/app/src/main/java/com/papi/nova/ui/NovaLaunchStreamOverride.kt new file mode 100644 index 000000000..83f402be4 --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaLaunchStreamOverride.kt @@ -0,0 +1,86 @@ +package com.papi.nova.ui + +import org.json.JSONObject +import kotlin.math.roundToInt + +/** + * Composes the one optimization blob a launch goes out with. + * + * A pick here used to replace the host's /optimize blob with a synthetic one, which + * silently dropped the stability block -- and with it the recovery clamp -- as a side + * effect of choosing a resolution. Composing over a deep copy keeps everything the host + * said and changes only what was actually chosen: + * - a resolution pick pins width x height and leaves the fps clamp standing; + * - an fps pin (Tuning = High FPS) pins the rate and releases the safe-target clamp + * explicitly, through the safe_target_fps_relaxed field the resolver already honors + * -- an informed override rather than an accident of blob replacement. + * + * The fps that launches must always be re-derivable from the blob that launches: + * whoever calls this must hand the SAME composed blob to both the stream-fps + * resolution and the launch intent, or Game.kt's re-resolution will disagree with + * the fps it was given. + */ +object NovaLaunchStreamOverride { + + const val NORMALIZATION_REASON = "nova_play_setup_override" + + /** + * The client-side fps pin: Tuning = High FPS means the Settings frame rate, + * guaranteed. The other preferences leave the host in control. + */ + fun highFpsPin(preference: String, settingsFps: Float): Int? = + if (preference.trim().lowercase() == "high_fps" && settingsFps > 0f) { + settingsFps.roundToInt() + } else { + null + } + + fun compose( + raw: JSONObject?, + resolution: NovaDisplayResolutionChoice?, + fpsOverride: Int?, + fallbackWidth: Int, + fallbackHeight: Int, + fallbackFps: Int, + ): JSONObject? { + if (resolution == null && fpsOverride == null) { + return raw + } + + val composed = raw?.let { JSONObject(it.toString()) } ?: JSONObject() + val rawMode = parseMode(composed.optString("display_mode", "")) + val chosenMode = parseMode(resolution?.targetMode.orEmpty()) + + val width = chosenMode?.width ?: rawMode?.width ?: fallbackWidth + val height = chosenMode?.height ?: rawMode?.height ?: fallbackHeight + val fps = fpsOverride ?: chosenMode?.fps ?: rawMode?.fps ?: fallbackFps + + composed.put("display_mode", "${width}x${height}x$fps") + composed.put("paired_profile_applied", true) + composed.put("normalization_reason", NORMALIZATION_REASON) + if (resolution != null) { + composed.put("display_planner_choice", resolution.id) + } + if (fpsOverride != null) { + composed.put("safe_target_fps_relaxed", true) + composed.put("effective_target_fps", fpsOverride.toDouble()) + } + return composed + } + + private data class Mode(val width: Int, val height: Int, val fps: Int) + + private fun parseMode(mode: String): Mode? { + val parts = mode.trim().split('x', 'X') + if (parts.size != 3) { + return null + } + val width = parts[0].toIntOrNull() ?: return null + val height = parts[1].toIntOrNull() ?: return null + val fps = parts[2].toFloatOrNull()?.roundToInt() ?: return null + if (width <= 0 || height <= 0 || fps <= 0) { + return null + } + return Mode(width, height, fps) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 575f87288..3736ddd0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -294,7 +294,7 @@ Polaris decides from how each session went. Holds resolution and bitrate. Drops frames before it drops sharpness. Plays it safe. Holds a steadier, lower target instead of chasing peaks. - Chases frame rate. Softens the picture when the host cannot hold both. + Pins your Settings frame rate. Softens the picture rather than give up the rate. Launches the game itself. Nothing else opens. Opens Big Picture first, which takes the controller until the game starts. Host defaults, profile and Auto Quality for every game @@ -340,6 +340,9 @@ If you changed the resolution Tuning If you changed the tuning + Pins %1$d FPS from your Settings frame rate + Pins %1$d FPS · overrides the recovery hold (host would run %2$d) + Launch %1$d FPS · your pick If you changed how Steam starts Its own display, made for this session. The desk keeps its own screen. Uses the host\'s virtual display driver. Advanced, and needs SudoVDA on the host. diff --git a/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt b/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt index 3c0417313..89f2c8678 100644 --- a/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt +++ b/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt @@ -265,6 +265,52 @@ class StreamSyncManagerTest { assertEquals(120f, targetFps, 0.01f) } + @Test + fun resolveAutoSafeTargetFps_pairedOverrideIsStillClampedByConfirmedRecovery() { + // The launch composer relies on this ordering: a composed blob that keeps the + // stability block gets the paired display_mode pin AND the recovery min-clamp, + // so a resolution pick alone can never discard the safe target. + val optimization = JSONObject( + "{\"display_mode\":\"1440x810x60\",\"safe_target_fps\":30,\"source\":\"history_safe\"," + + "\"paired_profile_applied\":true," + + "\"stability\":{\"mode\":\"stability_first\",\"auto_action\":\"apply_recovery\"," + + "\"safe_profile\":{\"target_fps\":30}}}" + ) + + val targetFps = StreamSyncManager.resolveAutoSafeTargetFps(120f, optimization) + + assertEquals(30f, targetFps, 0.01f) + } + + @Test + fun resolveAutoSafeTargetFps_pairedOverrideWithRelaxedFlagPinsDisplayModeFps() { + // And this is the composer's explicit release: safe_target_fps_relaxed on the + // same blob is what lets an informed fps pin win over a confirmed recovery. + val optimization = JSONObject( + "{\"display_mode\":\"1440x810x120\",\"safe_target_fps\":30,\"source\":\"history_safe\"," + + "\"paired_profile_applied\":true,\"safe_target_fps_relaxed\":true," + + "\"effective_target_fps\":120," + + "\"stability\":{\"mode\":\"stability_first\",\"auto_action\":\"apply_recovery\"," + + "\"safe_profile\":{\"target_fps\":30}}}" + ) + + val targetFps = StreamSyncManager.resolveAutoSafeTargetFps(60f, optimization) + + assertEquals(120f, targetFps, 0.01f) + } + + @Test + fun resolveAutoSafeBitrateKbps_pairedOverrideStaysClampedByConfirmedRecovery() { + val optimization = JSONObject( + "{\"target_bitrate_kbps\":40000,\"source\":\"history_safe\"," + + "\"paired_profile_applied\":true," + + "\"stability\":{\"mode\":\"stability_first\"," + + "\"safe_profile\":{\"target_bitrate_kbps\":8000}}}" + ) + + assertEquals(8000, StreamSyncManager.resolveAutoSafeBitrateKbps(20000, optimization)) + } + @Test fun requiresLaunchPreflightReview_ignoresMatchingRequestedAndEffectiveFps() { val optimization = JSONObject( diff --git a/app/src/test/java/com/papi/nova/ui/NovaDisplayResolutionPlannerTest.kt b/app/src/test/java/com/papi/nova/ui/NovaDisplayResolutionPlannerTest.kt index a62de7667..b604b4ab2 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaDisplayResolutionPlannerTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaDisplayResolutionPlannerTest.kt @@ -50,25 +50,13 @@ class NovaDisplayResolutionPlannerTest { } @Test - fun selectedPresetBuildsLaunchOptimizationOverrideForGameStartup() { - val choice = NovaDisplayResolutionChoice( - id = "performance", - title = "Performance", - targetMode = "1280x800x90", - badge = "0.5x downscale", - reason = "Favor frame pacing.", - advanced = false, - custom = false, - safe = true, - recommended = false - ) - - val json = NovaDisplayResolutionPlanner.buildLaunchOptimizationOverride(choice, source = "nova_display_planner") - - assertEquals("1280x800x90", json.optString("display_mode")) - assertTrue(json.optBoolean("paired_profile_applied")) - assertEquals("nova_display_planner", json.optString("source")) - assertEquals("display_resolution_planner", json.optString("normalization_reason")) + fun resolutionLabelDropsTheHostRateAndKeepsOddModesVerbatim() { + // The trailing rate is the host's plan, not this row's decision; a mode the + // parser does not recognize is shown as served rather than mangled. + assertEquals("1280x800", NovaDisplayResolutionPlanner.resolutionLabel("1280x800x90")) + assertEquals("1920x1080", NovaDisplayResolutionPlanner.resolutionLabel("1920x1080x59.94")) + assertEquals("1920x1080", NovaDisplayResolutionPlanner.resolutionLabel("1920x1080")) + assertEquals("", NovaDisplayResolutionPlanner.resolutionLabel("")) } @Test diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt index 0e1e7cee5..f45f39f76 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt @@ -133,10 +133,12 @@ class NovaLaunchSourceGuardTest { nvHttp.contains("launchMode=force_private_stream") ) assertTrue( - "a launch must wait for the preflight the desktop Steam guard is read from, rather than taking a null blob as consent", + "a launch must wait for the preflight the desktop Steam guard is read from -- guarded on the " + + "RAW blob, because a resolution pick or fps pin makes the composed blob non-null while " + + "the guard's answer is still on the wire", detail.contains("val preflightInFlight: Boolean = false") && detail.contains("NovaGameDetailOptimizationState(preflightInFlight = true)") && - detail.contains("if (optimization == null && optimizationState.preflightInFlight) {") && + detail.contains("if (optimizationState.rawOptimization == null && optimizationState.preflightInFlight) {") && detail.contains("if (pendingLaunch) attemptLaunch()") ) assertTrue( @@ -409,10 +411,12 @@ class NovaLaunchSourceGuardTest { ) assertTrue( - "Launch Options should switch to Polaris display planner rows when display_planner is advertised", + "Launch Options should switch to Polaris display planner rows when display_planner is advertised, " + + "and a pick composes over the host blob rather than replacing it -- replacement is what " + + "silently discarded the recovery clamp", detail.contains("game.displayPlanner") && detail.contains("NovaDisplayResolutionPlanner.from(") && - detail.contains("NovaDisplayResolutionPlanner.buildLaunchOptimizationOverride(") + detail.contains("NovaLaunchStreamOverride.compose(") ) assertTrue( "Planner choices are a row with a value rather than redundant Press A badges. The " + diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchStreamOverrideTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchStreamOverrideTest.kt new file mode 100644 index 000000000..c2b6119f1 --- /dev/null +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchStreamOverrideTest.kt @@ -0,0 +1,106 @@ +package com.papi.nova.ui + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Config(sdk = [33]) +@RunWith(RobolectricTestRunner::class) +class NovaLaunchStreamOverrideTest { + + private fun choice(targetMode: String, id: String = "balanced") = NovaDisplayResolutionChoice( + id = id, + title = "Balanced", + targetMode = targetMode, + badge = "", + reason = "", + advanced = false, + custom = false, + safe = true, + recommended = false, + ) + + private fun recoveryBlob(): JSONObject = JSONObject( + "{\"display_mode\":\"1920x1080x30\",\"safe_target_fps\":30,\"source\":\"history_safe\"," + + "\"target_bitrate_kbps\":8000," + + "\"profile_state\":{\"label\":\"Recovery\"}," + + "\"stability\":{\"mode\":\"stability_first\",\"safe_profile\":{\"target_fps\":30}}}" + ) + + @Test + fun nothingChosenReturnsTheRawBlobUntouched() { + val raw = recoveryBlob() + assertSame(raw, NovaLaunchStreamOverride.compose(raw, null, null, 1920, 1080, 60)) + assertNull(NovaLaunchStreamOverride.compose(null, null, null, 1920, 1080, 60)) + } + + @Test + fun resolutionPickKeepsTheStabilityBlockAndTheHostFps() { + val raw = recoveryBlob() + val composed = NovaLaunchStreamOverride.compose(raw, choice("1440x810x60"), null, 1920, 1080, 120)!! + + // Resolution from the pick, fps from the pick's own target mode. + assertEquals("1440x810x60", composed.getString("display_mode")) + assertTrue(composed.getBoolean("paired_profile_applied")) + assertEquals("balanced", composed.getString("display_planner_choice")) + // The honesty fix: the recovery clamp's inputs survive the pick. + assertEquals("stability_first", composed.getJSONObject("stability").getString("mode")) + assertEquals(30.0, composed.getDouble("safe_target_fps"), 0.0) + assertEquals(8000, composed.getInt("target_bitrate_kbps")) + assertFalse(composed.optBoolean("safe_target_fps_relaxed", false)) + } + + @Test + fun fpsPinReleasesTheSafeTargetExplicitly() { + val raw = recoveryBlob() + val composed = NovaLaunchStreamOverride.compose(raw, null, 120, 1280, 800, 60)!! + + // Resolution from the host blob, fps from the pin. + assertEquals("1920x1080x120", composed.getString("display_mode")) + assertTrue(composed.getBoolean("safe_target_fps_relaxed")) + assertEquals(120.0, composed.getDouble("effective_target_fps"), 0.0) + // Still composed, never replaced: stability travels with the release flag. + assertEquals("stability_first", composed.getJSONObject("stability").getString("mode")) + } + + @Test + fun resolutionPickAndFpsPinComposeIntoOneMode() { + val composed = NovaLaunchStreamOverride.compose(recoveryBlob(), choice("1440x810x60"), 120, 1280, 800, 60)!! + assertEquals("1440x810x120", composed.getString("display_mode")) + assertTrue(composed.getBoolean("safe_target_fps_relaxed")) + } + + @Test + fun missingBlobFallsBackToTheSettingsMode() { + val composed = NovaLaunchStreamOverride.compose(null, null, 90, 1280, 800, 60)!! + assertEquals("1280x800x90", composed.getString("display_mode")) + + val pinless = NovaLaunchStreamOverride.compose(JSONObject(), choice("x-bad-mode"), null, 1280, 800, 60)!! + assertEquals("1280x800x60", pinless.getString("display_mode")) + } + + @Test + fun composingNeverMutatesTheInputBlob() { + val raw = recoveryBlob() + val before = raw.toString() + NovaLaunchStreamOverride.compose(raw, choice("1440x810x60"), 120, 1280, 800, 60) + assertEquals(before, raw.toString()) + } + + @Test + fun highFpsPinComesOnlyFromTheHighFpsPreference() { + assertEquals(120, NovaLaunchStreamOverride.highFpsPin("high_fps", 120f)) + assertEquals(60, NovaLaunchStreamOverride.highFpsPin(" HIGH_FPS ", 59.94f)) + assertNull(NovaLaunchStreamOverride.highFpsPin("auto", 120f)) + assertNull(NovaLaunchStreamOverride.highFpsPin("quality", 120f)) + assertNull(NovaLaunchStreamOverride.highFpsPin("stability", 120f)) + assertNull(NovaLaunchStreamOverride.highFpsPin("high_fps", 0f)) + } +}