Skip to content
Merged
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
12 changes: 11 additions & 1 deletion app/src/main/java/com/papi/nova/Game.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 17 additions & 4 deletions app/src/main/java/com/papi/nova/ShortcutTrampoline.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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"
}
}
21 changes: 9 additions & 12 deletions app/src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
64 changes: 52 additions & 12 deletions app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import org.json.JSONObject
import java.util.Locale
import kotlin.math.abs
import kotlin.math.round
import kotlin.math.roundToInt


/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -795,6 +810,11 @@ class NovaGameDetailActivity : NovaActivity() {
*/
fun buildPlaySetupRows(): List<NovaPlaySetupRowState> {
val rows = mutableListOf<NovaPlaySetupRowState>()
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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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 ->
Expand All @@ -877,6 +905,7 @@ class NovaGameDetailActivity : NovaActivity() {
onSelect = { selectProfilePreference(value) },
)
},
overridden = fpsPin != null,
)

if (uiState.showSteamLaunchMode) {
Expand Down Expand Up @@ -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),
Expand Down
86 changes: 86 additions & 0 deletions app/src/main/java/com/papi/nova/ui/NovaLaunchStreamOverride.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@
<string name="nova_play_setup_pref_auto">Polaris decides from how each session went.</string>
<string name="nova_play_setup_pref_quality">Holds resolution and bitrate. Drops frames before it drops sharpness.</string>
<string name="nova_play_setup_pref_stability">Plays it safe. Holds a steadier, lower target instead of chasing peaks.</string>
<string name="nova_play_setup_pref_high_fps">Chases frame rate. Softens the picture when the host cannot hold both.</string>
<string name="nova_play_setup_pref_high_fps">Pins your Settings frame rate. Softens the picture rather than give up the rate.</string>
<string name="nova_play_setup_steam_direct">Launches the game itself. Nothing else opens.</string>
<string name="nova_play_setup_steam_big_picture">Opens Big Picture first, which takes the controller until the game starts.</string>
<string name="nova_play_setup_every_game_caption">Host defaults, profile and Auto Quality for every game</string>
Expand Down Expand Up @@ -340,6 +340,9 @@
<string name="nova_play_setup_strip_resolution">If you changed the resolution</string>
<string name="nova_play_setup_tuning">Tuning</string>
<string name="nova_play_setup_strip_tuning">If you changed the tuning</string>
<string name="nova_play_setup_tuning_pins">Pins %1$d FPS from your Settings frame rate</string>
<string name="nova_play_setup_tuning_pins_over_hold">Pins %1$d FPS · overrides the recovery hold (host would run %2$d)</string>
<string name="nova_play_setup_launch_pinned_fps">Launch %1$d FPS · your pick</string>
<string name="nova_play_setup_strip_steam">If you changed how Steam starts</string>
<string name="nova_play_setup_compare_private">Its own display, made for this session. The desk keeps its own screen.</string>
<string name="nova_play_setup_compare_virtual">Uses the host\'s virtual display driver. Advanced, and needs SudoVDA on the host.</string>
Expand Down
46 changes: 46 additions & 0 deletions app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading