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
41 changes: 24 additions & 17 deletions app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,19 @@ class NovaGameDetailActivity : NovaActivity() {
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)
// The row used to show only the saved ask; for the asks the host
// owns, the outcome is the half that was never said anywhere.
else -> when (
val outcome = novaTuningOutcome(optimizationState.rawOptimization, profilePreference)
) {
is NovaTuningOutcome.Applied -> getString(R.string.nova_play_setup_tuning_applied)
is NovaTuningOutcome.Declined -> if (outcome.reason.isNotBlank()) {
getString(R.string.nova_play_setup_tuning_declined, outcome.reason)
} else {
getString(R.string.nova_play_setup_tuning_declined_no_reason)
}
else -> getString(R.string.nova_game_detail_profile_caption)
}
},
value = getString(AutoQualityProfilePreferences.shortLabelRes(profilePreference)),
stripTitle = getString(R.string.nova_play_setup_strip_tuning),
Expand Down Expand Up @@ -1095,21 +1107,11 @@ class NovaGameDetailActivity : NovaActivity() {
} else if (optimizationState.reviewRequired) {
getString(R.string.nova_library_review_and_launch)
} else {
// 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)
}
// Pin-aware in the summary itself now, so the button just reads it.
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 Expand Up @@ -1786,10 +1788,15 @@ class NovaGameDetailActivity : NovaActivity() {
}
}

val clientPreferences = PreferenceConfiguration.readPreferences(this)
return NovaGameDetailOptimizationState(
ai = aiCard,
stability = stabilityCard,
profileSummary = buildNovaLaunchProfileSummary(opt),
profileSummary = buildNovaLaunchProfileSummary(
opt,
clientAskedFps = clientPreferences.fps.toDouble(),
clientFpsPinned = NovaLaunchStreamOverride.highFpsPin(profilePreference, clientPreferences.fps) != null,
),
rawOptimization = opt,
reviewRequired = StreamSyncManager.requiresLaunchPreflightReview(opt),
reviewReason = StreamSyncManager.launchPreflightReviewReason(opt),
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,38 @@ internal fun novaProfilePreferenceConsequenceRes(value: String): Int =
else -> R.string.nova_play_setup_pref_auto
}

/**
* What the host actually did with a saved tuning ask. Auto asks for nothing and
* High FPS is binding client-side, so only quality/stability have an outcome the
* host owns -- and a host that predates preference_applied gets Default, never a
* fabricated decline read off a missing field.
*/
internal sealed class NovaTuningOutcome {
object Default : NovaTuningOutcome()
object Applied : NovaTuningOutcome()
data class Declined(val reason: String) : NovaTuningOutcome()
}

internal fun novaTuningOutcome(optimization: JSONObject?, preference: String): NovaTuningOutcome {
if (optimization == null) return NovaTuningOutcome.Default
val normalized = preference.trim().lowercase()
if (normalized == "auto" || normalized == "high_fps") return NovaTuningOutcome.Default
val profileState = optimization.optJSONObject("profile_state")
val appliedKnown = optimization.has("preference_applied") ||
profileState?.has("preference_applied") == true
if (!appliedKnown) return NovaTuningOutcome.Default
val applied = optimization.optBoolean(
"preference_applied",
profileState?.optBoolean("preference_applied", false) ?: false
)
if (applied) return NovaTuningOutcome.Applied
val reason = optimization.optString(
"preference_blocked_reason",
profileState?.optString("preference_blocked_reason", "") ?: ""
)
return NovaTuningOutcome.Declined(if (reason.isBlank()) "" else novaLaunchIssueLabel(reason))
}

/** The same, for the two ways Steam can be handed the game. */
internal fun novaSteamLaunchConsequenceRes(value: String): Int =
// "big-picture" with the hyphen is what SteamLaunchContract.normalizeMode returns, and
Expand Down
64 changes: 52 additions & 12 deletions app/src/main/java/com/papi/nova/ui/NovaLaunchProfileSummary.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,25 @@ data class NovaLaunchProfileSummary(
val freshnessLine: String,
val historyLines: List<String>,
val showRetryHighFps: Boolean,
val retryHighFpsLabel: String
val retryHighFpsLabel: String,
/**
* What is holding the granted rate below the client's ask, prettified for display
* ("Held by History Safe Profile"). Blank when nothing is, or when a pin outranks
* the hold anyway.
*/
val grantHoldReason: String = ""
)

internal fun buildNovaLaunchProfileSummary(
optimization: JSONObject?,
nowSeconds: Long = System.currentTimeMillis() / 1000L
nowSeconds: Long = System.currentTimeMillis() / 1000L,
/** The fps this client actually asked for (its Settings frame rate); 0 = unknown. */
clientAskedFps: Double = 0.0,
/** True when Tuning = High FPS is pinning [clientAskedFps] over the host's plan. */
clientFpsPinned: Boolean = false
): NovaLaunchProfileSummary? {
if (optimization == null) return null
val pinnedFps = if (clientFpsPinned && clientAskedFps > 0.0) clientAskedFps else 0.0

val profileState = optimization.optJSONObject("profile_state")
val currentProfile = profileState?.optJSONObject("current_profile")
Expand Down Expand Up @@ -82,6 +93,9 @@ internal fun buildNovaLaunchProfileSummary(
}

val primaryLabel = when {
// A pin outranks whatever the host planned, so the verb states the pin --
// promising a recovery launch that will not happen is worse than saying less.
pinnedFps > 0.0 -> "Launch ${formatFps(pinnedFps)} FPS · your pick"
trialProfile && effectiveFps > 0.0 -> "Try High FPS stream ${formatFps(effectiveFps)} FPS"
selectedLabel.equals("High FPS stream", ignoreCase = true) && effectiveFps > 0.0 ->
"Launch High FPS stream ${formatFps(effectiveFps)} FPS"
Expand All @@ -97,10 +111,22 @@ internal fun buildNovaLaunchProfileSummary(
} else {
"Requested: $preferenceLabel"
}
val selectedLine = if (effectiveFps > 0.0) {
"Selected: $selectedLabel / ${formatFps(effectiveFps)} FPS"
} else {
"Selected: $selectedLabel"
// The ask-vs-grant gap, stated where the grant is stated. The client ask is this
// client's Settings frame rate -- the host's own requested_* fields cannot be
// trusted to echo it, and the gap between the two is the single fact the old
// screen never said anywhere.
val askedGap = pinnedFps <= 0.0 &&
clientAskedFps > 0.0 &&
effectiveFps > 0.0 &&
clientAskedFps > effectiveFps + 0.5
val selectedLine = when {
pinnedFps > 0.0 && effectiveFps > 0.0 && pinnedFps > effectiveFps + 0.5 ->
"Selected: ${formatFps(pinnedFps)} FPS pinned (host offered $selectedLabel / ${formatFps(effectiveFps)} FPS)"
pinnedFps > 0.0 -> "Selected: ${formatFps(pinnedFps)} FPS pinned"
askedGap && effectiveFps > 0.0 ->
"Selected: $selectedLabel / ${formatFps(effectiveFps)} FPS · you asked ${formatFps(clientAskedFps)}"
effectiveFps > 0.0 -> "Selected: $selectedLabel / ${formatFps(effectiveFps)} FPS"
else -> "Selected: $selectedLabel"
}

val reasonText = profileState
Expand Down Expand Up @@ -129,7 +155,7 @@ internal fun buildNovaLaunchProfileSummary(
}

val issue = if (healthyPerformance) "" else reportedIssue
val limitingLine = issue.takeIf { it.isNotBlank() }?.let { "Limited by: ${issueLabel(it)}" }.orEmpty()
val limitingLine = issue.takeIf { it.isNotBlank() }?.let { "Limited by: ${novaLaunchIssueLabel(it)}" }.orEmpty()

val updatedAt = lastResult?.optLong("updated_at", 0L) ?: 0L
val freshnessLine = when {
Expand All @@ -150,12 +176,25 @@ internal fun buildNovaLaunchProfileSummary(
"preference_applied",
profileState?.optBoolean("preference_applied", false) ?: false
)
val showRetryHighFps = !trialProfile &&
// A pin makes the trial pointless: the launch already goes out at the asked rate.
val showRetryHighFps = pinnedFps <= 0.0 &&
!trialProfile &&
highFpsHeldBelowRequest &&
(
actions?.optBoolean("can_retry_high_fps", false) == true ||
(preference == "high_fps" && !preferenceApplied)
)
val blockedReason = optimization.optString(
"preference_blocked_reason",
profileState?.optString("preference_blocked_reason", "") ?: ""
)
val grantHoldReason = when {
!askedGap -> ""
blockedReason.isNotBlank() -> "Held by ${novaLaunchIssueLabel(blockedReason)}"
issue.isNotBlank() -> "Held by ${novaLaunchIssueLabel(issue)}"
selectedLabel.startsWith("Recovery", ignoreCase = true) -> "Held by the recovery profile"
else -> ""
}
val retryLabel = if (requestedFps > effectiveFps + 0.5) {
"Try ${formatFps(requestedFps)} FPS once"
} else {
Expand Down Expand Up @@ -195,7 +234,8 @@ internal fun buildNovaLaunchProfileSummary(
freshnessLine = freshnessLine,
historyLines = historyLines,
showRetryHighFps = showRetryHighFps,
retryHighFpsLabel = retryLabel
retryHighFpsLabel = retryLabel,
grantHoldReason = grantHoldReason
)
}

Expand Down Expand Up @@ -244,7 +284,7 @@ private fun buildNoticeDetail(lastResult: JSONObject?, issue: String): String {
"pacing", "frame_pacing" ->
"Frames arrived unevenly, which can look like judder even when average FPS is high."
"" -> ""
else -> "Polaris reported ${issueLabel(issue)} for the last session."
else -> "Polaris reported ${novaLaunchIssueLabel(issue)} for the last session."
}
return listOf(evidence, impact).filter { it.isNotBlank() }.joinToString(" ")
}
Expand Down Expand Up @@ -294,7 +334,7 @@ private fun buildHistoryLines(
lines += "Last: grade $grade"
}
if (issue.isNotBlank()) {
lines += "Issue: ${issueLabel(issue)}"
lines += "Issue: ${novaLaunchIssueLabel(issue)}"
}
if (selectedLabel.startsWith("Recovery", ignoreCase = true)) {
lines += "Next: one clean launch can release recovery, or reset this game profile."
Expand Down Expand Up @@ -452,7 +492,7 @@ private fun selectedLabelFromState(state: String): String {
}
}

private fun issueLabel(issue: String): String {
internal fun novaLaunchIssueLabel(issue: String): String {
return when (normalized(issue)) {
"host_render", "host_render_limited" -> "Host Render"
"decoder", "decoder_path" -> "Decoder Path"
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/com/papi/nova/ui/NovaPlaySetup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,12 @@ internal fun novaPlaySetupPlan(
facts += NovaPlaySetupFact(
key = askedKey,
value = asked,
detail = if (granted.isNotBlank()) grantedFormat.format(granted) else "",
// The why rides with the grant: "Granted: Recovery profile / 30 FPS ·
// Held by History Safe Profile" is the whole story in one fact.
detail = listOfNotNull(
granted.takeIf { it.isNotBlank() }?.let { grantedFormat.format(it) },
summary.grantHoldReason.takeIf { it.isNotBlank() },
).joinToString(" · "),
)
}

Expand Down
4 changes: 3 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,9 @@
<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_tuning_applied">Applied by host</string>
<string name="nova_play_setup_tuning_declined">Host declined · %1$s</string>
<string name="nova_play_setup_tuning_declined_no_reason">Host declined this preference</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
62 changes: 62 additions & 0 deletions app/src/test/java/com/papi/nova/ui/NovaLaunchProfileSummaryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -798,4 +798,66 @@ class NovaLaunchProfileSummaryTest {
)
}

private fun recoveryHoldOptimization(): JSONObject = JSONObject(
"{" +
"\"source\":\"history_safe\"," +
"\"display_mode\":\"1920x1080x30\"," +
"\"effective_target_fps\":30," +
"\"preference\":\"high_fps\"," +
"\"preference_applied\":false," +
"\"preference_blocked_reason\":\"history_safe_profile\"," +
"\"profile_state\":{" +
"\"state\":\"recovering\"," +
"\"label\":\"Recovery\"," +
"\"current_profile\":{\"display_mode\":\"1920x1080x30\",\"target_fps\":30}" +
"}" +
"}"
)

@Test
fun recoveryHoldWithHigherClientAskStatesTheGapAndItsReason() {
val summary = buildNovaLaunchProfileSummary(
recoveryHoldOptimization(),
nowSeconds = 1780000060L,
clientAskedFps = 120.0
)

requireNotNull(summary)
assertEquals("Selected: Recovery profile / 30 FPS · you asked 120", summary.selectedLine)
assertEquals("Held by History Safe Profile", summary.grantHoldReason)
}

@Test
fun clientFpsPinOwnsTheHeadlineAndRetiresTheTrial() {
val summary = buildNovaLaunchProfileSummary(
recoveryHoldOptimization(),
nowSeconds = 1780000060L,
clientAskedFps = 120.0,
clientFpsPinned = true
)

requireNotNull(summary)
assertEquals("Launch 120 FPS · your pick", summary.primaryLaunchLabel)
assertEquals(
"Selected: 120 FPS pinned (host offered Recovery profile / 30 FPS)",
summary.selectedLine
)
// The pin already launches at the asked rate, so the one-shot trial would be noise.
assertFalse(summary.showRetryHighFps)
assertEquals("", summary.grantHoldReason)
}

@Test
fun matchingClientAskAddsNoGapSuffix() {
val summary = buildNovaLaunchProfileSummary(
recoveryHoldOptimization(),
nowSeconds = 1780000060L,
clientAskedFps = 30.0
)

requireNotNull(summary)
assertEquals("Selected: Recovery profile / 30 FPS", summary.selectedLine)
assertEquals("", summary.grantHoldReason)
}

}
60 changes: 60 additions & 0 deletions app/src/test/java/com/papi/nova/ui/NovaTuningOutcomeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.papi.nova.ui

import org.json.JSONObject
import org.junit.Assert.assertEquals
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 NovaTuningOutcomeTest {

@Test
fun autoAndHighFpsHaveNoHostOutcome() {
val blob = JSONObject("{\"preference_applied\":false}")
assertEquals(NovaTuningOutcome.Default, novaTuningOutcome(blob, "auto"))
// High FPS is binding client-side; what the host thinks of the ask is moot.
assertEquals(NovaTuningOutcome.Default, novaTuningOutcome(blob, "high_fps"))
assertEquals(NovaTuningOutcome.Default, novaTuningOutcome(null, "quality"))
}

@Test
fun hostsWithoutTheFieldNeverReadAsDeclines() {
// preference_applied absent everywhere: an older host, not a decline.
assertEquals(
NovaTuningOutcome.Default,
novaTuningOutcome(JSONObject("{\"display_mode\":\"1920x1080x60\"}"), "quality")
)
}

@Test
fun appliedAndDeclinedReadFromEitherLevel() {
assertEquals(
NovaTuningOutcome.Applied,
novaTuningOutcome(JSONObject("{\"preference_applied\":true}"), "quality")
)
assertEquals(
NovaTuningOutcome.Applied,
novaTuningOutcome(
JSONObject("{\"profile_state\":{\"preference_applied\":true}}"),
"stability"
)
)
assertEquals(
NovaTuningOutcome.Declined("History Safe Profile"),
novaTuningOutcome(
JSONObject(
"{\"preference_applied\":false," +
"\"preference_blocked_reason\":\"history_safe_profile\"}"
),
"quality"
)
)
assertEquals(
NovaTuningOutcome.Declined(""),
novaTuningOutcome(JSONObject("{\"preference_applied\":false}"), "stability")
)
}
}
Loading