diff --git a/CHANGELOG.md b/CHANGELOG.md index 06947fe0..7eadac28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.1.0 - 2026-05-20 + +- Added structured stream performance samples from the video renderer so NovaHUD can consume typed metrics without reparsing the legacy overlay text path. +- Reworked the NovaHUD sparkline path around a fixed primitive ring buffer and focused snapshots to reduce stream-loop allocation pressure. +- Routed structured performance samples into NovaHUD while preserving the legacy performance overlay behavior. +- Expanded Baseline Profile coverage for library detail, settings, and launch-adjacent Compose surfaces. +- Documented the JNI bridge measurement gate for future `@FastNative` and `@CriticalNative` work instead of annotating JNI calls before profiling proves they are safe. +- Kept the lock-screen overlay retryable by treating Polaris unlock responses as successful only when the host reports `success: true`. +- Normalized raw `idle` stream progress into the initializing overlay state so handheld users do not see an internal state label. +- Added saved-host port recovery so Nova retries a stale local address on the default Polaris HTTP port and persists the corrected port after a successful poll. +- Verified the debug ARM64 APK on a Retroid Pocket 6 over wireless ADB with Polaris library launch, HEVC stream resume, NovaHUD enablement, Command Center disconnect, and clean log/crash-buffer checks. + ## 1.0.10 - 2026-05-19 - Migrated upgraded installs that still carried the old Balanced 720p stream resolution so Shield, Retroid, and Android TV clients request 1080p after updating. diff --git a/README.md b/README.md index ee15ba9d..9611a030 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ tuning instead of hiding everything behind a generic game grid. [![License](https://img.shields.io/github/license/papi-ux/nova?style=for-the-badge&color=4c5265&labelColor=1a1a2e)](LICENSE.txt) [![Release](https://img.shields.io/github/v/release/papi-ux/nova?style=for-the-badge&color=4ade80&labelColor=1a1a2e&label=latest)](https://github.com/papi-ux/nova/releases/latest) -[Quick Start](#quick-start) · [What's New](#whats-new-in-v1010) · [Install](#install) · [Compatibility](#compatibility) · [Tour](#tour) · [Polaris](#use-with-polaris) · [Docs](#docs) · [FAQ](#faq) · [Security](SECURITY.md) · [Changelog](CHANGELOG.md) · [Roadmap](ROADMAP.md) +[Quick Start](#quick-start) · [What's New](#whats-new-in-v110) · [Install](#install) · [Compatibility](#compatibility) · [Tour](#tour) · [Polaris](#use-with-polaris) · [Docs](#docs) · [FAQ](#faq) · [Security](SECURITY.md) · [Changelog](CHANGELOG.md) · [Roadmap](ROADMAP.md) **Support**: [Issues](https://github.com/papi-ux/nova/issues) · [Discussions](https://github.com/papi-ux/nova/discussions) @@ -51,15 +51,15 @@ tuning instead of hiding everything behind a generic game grid. If a sleeping host does not report a MAC address, open the host menu and choose **Edit Wake-on-LAN MAC**. Nova stores that address and reuses it for future wake requests, which helps VPN and routed setups where discovery metadata is incomplete. -## What's New in v1.0.10 +## What's New in v1.1.0 -Nova `v1.0.10` is an upgraded-install resolution hotfix for Polaris-backed streams. +Nova `v1.1.0` is a stream performance and release-hardening update for Polaris-backed play. -- **Upgraded-install repair**: Shield, Retroid, and Android TV installs that still had the old Balanced 720p stream setting are migrated to 1080p after updating. -- **Performance stays explicit**: Nova keeps the Performance preset at 720p and only repairs settings that still match the old Balanced default shape. -- **Cached Auto Safe guard**: Polaris cached launch profiles can no longer force 1080p-capable clients down to 720p unless a confirmed recovery profile is active. -- **Polaris launch validation**: the fix targets direct Polaris-backed launches as well as library launches, so the host sees a 1080p client request. -- **Regression coverage**: unit tests now cover the Balanced migration, Performance-preset guard, and cached Auto Safe 1080p floor. +- **Lower-overhead HUD metrics**: NovaHUD now consumes structured stream samples from the video renderer while preserving the legacy overlay path. +- **Hot-path allocation cleanup**: HUD sparkline samples use a fixed primitive buffer instead of rebuilding collection state during a stream. +- **Smoother first-run surfaces**: Baseline Profile generation now covers library detail, settings, and launch-adjacent Compose paths. +- **Measured JNI policy**: the JNI bridge now has a documented profiling gate for future `@FastNative` and `@CriticalNative` work. +- **Retroid 6 validation**: the ARM64 debug APK was smoke tested over wireless ADB with Polaris library launch, HEVC stream resume, NovaHUD, Command Center disconnect, and clean crash checks. See the [changelog](CHANGELOG.md) for the full release history. diff --git a/app/build.gradle b/app/build.gradle index eb6f8dab..cd79b324 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -39,8 +39,8 @@ android { minSdk 21 targetSdk 36 - versionName "1.0.10" - versionCode = 25 + versionName "1.1.0" + versionCode = 26 buildConfigField "boolean", "FDROID_BUILD", fdroidBuild.toString() diff --git a/app/src/main/java/com/papi/nova/Game.kt b/app/src/main/java/com/papi/nova/Game.kt index 8845fe97..977da0b4 100644 --- a/app/src/main/java/com/papi/nova/Game.kt +++ b/app/src/main/java/com/papi/nova/Game.kt @@ -24,6 +24,7 @@ import com.papi.nova.binding.video.CrashListener import com.papi.nova.binding.video.MediaCodecDecoderRenderer import com.papi.nova.binding.video.MediaCodecHelper import com.papi.nova.binding.video.PerfOverlayListener +import com.papi.nova.binding.video.PerfOverlaySample import com.papi.nova.nvstream.NvConnection import com.papi.nova.nvstream.NvConnectionListener import com.papi.nova.nvstream.StreamConfiguration @@ -4808,6 +4809,16 @@ novaHud!!.updateFromPerfText(text) } }) } +override fun onPerfSample(sample:PerfOverlaySample) { +runOnUiThread(object : Runnable { +override fun run() { + if (novaHud != null && novaHud!!.isShowing) +{ +novaHud!!.updateFromPerfSample(sample) +} +} +}) +} override fun onUsbPermissionPromptStarting() { // Disable PiP auto-enter while the USB permission prompt is on-screen. This prevents // us from entering PiP while the user is interacting with the OS permission dialog. diff --git a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt index 5e7b79e1..ea83c450 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt @@ -306,6 +306,10 @@ class PolarisApiClient @JvmOverloads constructor( ) } + @JvmStatic + fun parseUnlockResponse(json: JSONObject): Boolean = + json.optBoolean("success", false) + @JvmStatic fun parseCapabilitiesResponse(json: JSONObject): PolarisCapabilities { val features = json.optJSONObject("features") @@ -1322,7 +1326,9 @@ class PolarisApiClient @JvmOverloads constructor( execute(request).use { response -> if (response.code != 200) return false - true + val body = response.body.string() + if (body.isBlank()) return false + parseUnlockResponse(JSONObject(body)) } } catch (e: Exception) { LimeLog.warning("Nova: Unlock request failed: ${errorMessage(e)}") diff --git a/app/src/main/java/com/papi/nova/binding/video/MediaCodecDecoderRenderer.kt b/app/src/main/java/com/papi/nova/binding/video/MediaCodecDecoderRenderer.kt index e1bfa554..a51f008d 100644 --- a/app/src/main/java/com/papi/nova/binding/video/MediaCodecDecoderRenderer.kt +++ b/app/src/main/java/com/papi/nova/binding/video/MediaCodecDecoderRenderer.kt @@ -1530,6 +1530,24 @@ class MediaCodecDecoderRenderer( } sb.append(context.getString(R.string.perf_overlay_dectime, decodeTimeMs)) } + val packetLossPct = if (lastTwo.totalFrames > 0) { + lastTwo.framesLost.toDouble() / lastTwo.totalFrames.toDouble() * 100.0 + } else { + 0.0 + } + val perfSample = PerfOverlaySample( + fps = fps.totalFps.toDouble(), + incomingFps = fps.receivedFps.toDouble(), + renderedFps = fps.renderedFps.toDouble(), + width = initialWidth, + height = initialHeight, + codec = decoder, + rttMs = (rttInfo shr 32).toInt(), + rttVarianceMs = rttInfo.toInt(), + decodeTimeMs = decodeTimeMs.toDouble(), + packetLossPct = packetLossPct + ) + perfListener.onPerfSample(perfSample) val fullLog = sb.toString() perfListener.onPerfUpdate(fullLog) val targetFpsMatched = fps.totalFps.toInt() == prefs.fps.toInt() diff --git a/app/src/main/java/com/papi/nova/binding/video/PerfOverlayListener.kt b/app/src/main/java/com/papi/nova/binding/video/PerfOverlayListener.kt index d5473ac6..8b142dd2 100644 --- a/app/src/main/java/com/papi/nova/binding/video/PerfOverlayListener.kt +++ b/app/src/main/java/com/papi/nova/binding/video/PerfOverlayListener.kt @@ -2,4 +2,7 @@ package com.papi.nova.binding.video interface PerfOverlayListener { fun onPerfUpdate(text: String) + + fun onPerfSample(sample: PerfOverlaySample) { + } } diff --git a/app/src/main/java/com/papi/nova/binding/video/PerfOverlaySample.kt b/app/src/main/java/com/papi/nova/binding/video/PerfOverlaySample.kt new file mode 100644 index 00000000..515182cf --- /dev/null +++ b/app/src/main/java/com/papi/nova/binding/video/PerfOverlaySample.kt @@ -0,0 +1,14 @@ +package com.papi.nova.binding.video + +data class PerfOverlaySample( + val fps: Double, + val incomingFps: Double, + val renderedFps: Double, + val width: Int, + val height: Int, + val codec: String, + val rttMs: Int, + val rttVarianceMs: Int, + val decodeTimeMs: Double, + val packetLossPct: Double +) diff --git a/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt b/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt index 48129f9e..66e6f28c 100644 --- a/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt +++ b/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt @@ -686,74 +686,35 @@ class ComputerManagerService : Service() { @Throws(InterruptedException::class) private fun parallelPollPc(details: ComputerDetails): ComputerDetails? { - val localInfo = ParallelPollTuple(details.localAddress, details) - val manualInfo = ParallelPollTuple(details.manualAddress, details) - val remoteInfo = ParallelPollTuple(details.remoteAddress, details) - val ipv6Info = ParallelPollTuple(details.ipv6Address, details) + val pollTuples = buildParallelPollAddresses(details) + .map { address -> ParallelPollTuple(address, details) } // These must be started in order of precedence for the deduplication algorithm // to result in the correct behavior. val uniqueAddresses = HashSet() - startParallelPollThread(localInfo, uniqueAddresses) - startParallelPollThread(manualInfo, uniqueAddresses) - startParallelPollThread(remoteInfo, uniqueAddresses) - startParallelPollThread(ipv6Info, uniqueAddresses) + for (tuple in pollTuples) { + startParallelPollThread(tuple, uniqueAddresses) + } try { - // Check local first - synchronized(localInfo.completionLock) { - while (!localInfo.complete) { - localInfo.completionLock.wait() - } - - if (localInfo.returnedDetails != null) { - localInfo.returnedDetails!!.activeAddress = localInfo.address - return localInfo.returnedDetails - } - } - - // Now manual - synchronized(manualInfo.completionLock) { - while (!manualInfo.complete) { - manualInfo.completionLock.wait() - } - - if (manualInfo.returnedDetails != null) { - manualInfo.returnedDetails!!.activeAddress = manualInfo.address - return manualInfo.returnedDetails - } - } - - // Now remote IPv4 - synchronized(remoteInfo.completionLock) { - while (!remoteInfo.complete) { - remoteInfo.completionLock.wait() - } - - if (remoteInfo.returnedDetails != null) { - remoteInfo.returnedDetails!!.activeAddress = remoteInfo.address - return remoteInfo.returnedDetails - } - } - - // Now global IPv6 - synchronized(ipv6Info.completionLock) { - while (!ipv6Info.complete) { - ipv6Info.completionLock.wait() - } + for (tuple in pollTuples) { + synchronized(tuple.completionLock) { + while (!tuple.complete) { + tuple.completionLock.wait() + } - if (ipv6Info.returnedDetails != null) { - ipv6Info.returnedDetails!!.activeAddress = ipv6Info.address - return ipv6Info.returnedDetails + if (tuple.returnedDetails != null) { + tuple.returnedDetails!!.activeAddress = tuple.address + return tuple.returnedDetails + } } } } finally { // Stop any further polling if we've found a working address or we've been // interrupted by an attempt to stop polling. - localInfo.interrupt() - manualInfo.interrupt() - remoteInfo.interrupt() - ipv6Info.interrupt() + for (tuple in pollTuples) { + tuple.interrupt() + } } return null @@ -1007,6 +968,28 @@ class ComputerManagerService : Service() { private const val INITIAL_POLL_TRIES = 2 private const val EMPTY_LIST_THRESHOLD = 3 private const val POLL_DATA_TTL_MS = 30000 + + @JvmStatic + fun buildParallelPollAddresses(details: ComputerDetails): List { + val addresses = ArrayList() + + fun addUnique(address: ComputerDetails.AddressTuple?) { + if (address != null && !addresses.contains(address)) { + addresses.add(address) + } + } + + val localAddress = details.localAddress + addUnique(localAddress) + if (localAddress != null && localAddress.port != NvHTTP.DEFAULT_HTTP_PORT) { + addUnique(ComputerDetails.AddressTuple(localAddress.address, NvHTTP.DEFAULT_HTTP_PORT)) + } + addUnique(details.manualAddress) + addUnique(details.remoteAddress) + addUnique(details.ipv6Address) + + return addresses + } } } diff --git a/app/src/main/java/com/papi/nova/ui/NovaHudSparklineBuffer.kt b/app/src/main/java/com/papi/nova/ui/NovaHudSparklineBuffer.kt new file mode 100644 index 00000000..a474bf48 --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaHudSparklineBuffer.kt @@ -0,0 +1,46 @@ +package com.papi.nova.ui + +internal class NovaHudSparklineBuffer(private val capacity: Int = 60) { + private val values = FloatArray(capacity) + private val scratch = FloatArray(capacity) + private var nextIndex = 0 + private var sampleCount = 0 + + fun add(value: Float) { + values[nextIndex] = value + nextIndex = (nextIndex + 1) % capacity + if (sampleCount < capacity) { + sampleCount++ + } + } + + fun clear() { + nextIndex = 0 + sampleCount = 0 + } + + fun snapshot(): List { + val output = ArrayList(sampleCount) + for (i in 0 until sampleCount) { + output.add(valueAt(i)) + } + return output + } + + fun lowOnePercent(): Double { + if (sampleCount < 3) { + return 0.0 + } + for (i in 0 until sampleCount) { + scratch[i] = valueAt(i) + } + java.util.Arrays.sort(scratch, 0, sampleCount) + val index = (sampleCount * 0.01f).toInt().coerceIn(0, sampleCount - 1) + return scratch[index].toDouble() + } + + private fun valueAt(offset: Int): Float { + val start = if (sampleCount == capacity) nextIndex else 0 + return values[(start + offset) % capacity] + } +} diff --git a/app/src/main/java/com/papi/nova/ui/NovaStreamHud.kt b/app/src/main/java/com/papi/nova/ui/NovaStreamHud.kt index 62732b5e..5a7a6149 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaStreamHud.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaStreamHud.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.preference.PreferenceManager import com.papi.nova.api.PolarisSessionStatus +import com.papi.nova.binding.video.PerfOverlaySample import com.papi.nova.ui.compose.NovaComposeTheme import kotlin.math.abs @@ -41,7 +42,7 @@ class NovaStreamHud(private val activity: Activity) { private var degradedFrames = 0 private var recoveredFrames = 0 private var bitrateReduced = false - private val sparklineData = mutableListOf() + private val sparklineData = NovaHudSparklineBuffer() var onBitrateAdjust: ((Int) -> Unit)? = null @@ -163,6 +164,19 @@ class NovaStreamHud(private val activity: Activity) { } } + fun updateFromPerfSample(sample: PerfOverlaySample) { + activity.runOnUiThread { + if (hudView == null) return@runOnUiThread + updateFps(sample.fps) + width = sample.width + height = sample.height + updateLatency(sample.rttMs) + applyCodecLabel(sample.codec) + sessionStats.recordPacketLoss(sample.packetLossPct) + publishState() + } + } + fun setTargetBitrateKbps(bitrateKbps: Int) { currentBitrateKbps = bitrateKbps lastBitrateKbps = bitrateKbps @@ -229,12 +243,9 @@ class NovaStreamHud(private val activity: Activity) { } lastFps = fps sparklineData.add(fps.toFloat()) - if (sparklineData.size > 60) { - sparklineData.removeAt(0) - } sessionStats.recordFps( fps = fps, - lowOnePercentFps = NovaHudUiState.calculateLowOnePercent(sparklineData) + lowOnePercentFps = sparklineData.lowOnePercent() ) if (hostAdaptiveBitrateActive) { @@ -302,7 +313,8 @@ class NovaStreamHud(private val activity: Activity) { width = width, height = height, status = lastSessionStatus, - sparklineSamples = sparklineData + sparklineSamples = sparklineData.snapshot(), + lowOnePercentFps = sparklineData.lowOnePercent() ) } diff --git a/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt b/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt index 10976cba..98c98726 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt @@ -46,7 +46,8 @@ data class NovaSessionProgressUiState( ) fun from(state: String, message: String = ""): NovaSessionProgressUiState { - val index = stages.indexOfFirst { it.first == state } + val displayState = if (state == "idle") "initializing" else state + val index = stages.indexOfFirst { it.first == displayState } val title = stages.getOrNull(index)?.second ?: message.ifEmpty { state } val completed = if (index >= 0) { stages.take(index).map { it.second } @@ -54,7 +55,7 @@ data class NovaSessionProgressUiState( emptyList() } return NovaSessionProgressUiState( - state = state, + state = displayState, title = title, completedStages = completed ) diff --git a/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt b/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt index 4fa88c00..b26c0f98 100644 --- a/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt +++ b/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt @@ -10,6 +10,7 @@ import com.papi.nova.binding.input.GameInputDevice import com.papi.nova.binding.input.driver.UsbDriverService import com.papi.nova.binding.input.evdev.EvdevListener import com.papi.nova.binding.video.PerfOverlayListener +import com.papi.nova.binding.video.PerfOverlaySample import com.papi.nova.nvstream.NvConnectionListener import com.papi.nova.ui.ExternalControllerView import com.papi.nova.ui.GameGestures @@ -125,6 +126,7 @@ class KotlinGameRuntimeMigrationTest { Game::class.java.getMethod("toggleFloatingButtonVisibility") Game::class.java.getMethod("handleCommitText", CharSequence::class.java) Game::class.java.getMethod("handleDeleteSurroundingText", Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!) + Game::class.java.getMethod("onPerfSample", PerfOverlaySample::class.java) } @Test @@ -244,6 +246,16 @@ class KotlinGameRuntimeMigrationTest { assertFalse(bitrateAdjust.contains("Thread({")) } + @Test + fun gameForwardsStructuredPerfSamplesToNovaHud() { + val source = readGameSource() + + assertTrue(source.contains("override fun onPerfSample(sample:PerfOverlaySample)")) + assertTrue(source.contains("novaHud!!.updateFromPerfSample(sample)")) + assertTrue(source.contains("override fun onPerfUpdate(text:String)")) + assertTrue(source.contains("novaHud!!.updateFromPerfText(text)")) + } + private fun readGameSource(): String { return String(Files.readAllBytes(Path.of("src/main/java/com/papi/nova/Game.kt")), StandardCharsets.UTF_8) } 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 e7408883..32c1c109 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -13,6 +13,13 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class PolarisApiClientParsingTest { + @Test + fun parseUnlockResponse_requiresSuccessFlag() { + assertFalse(PolarisApiClient.parseUnlockResponse(JSONObject("{\"success\":false,\"was_locked\":true}"))) + assertTrue(PolarisApiClient.parseUnlockResponse(JSONObject("{\"success\":true,\"was_locked\":true}"))) + assertFalse(PolarisApiClient.parseUnlockResponse(JSONObject("{\"was_locked\":true}"))) + } + @Test fun parseCapabilitiesResponse_includesCursorVisibilityControl() { val json = JSONObject( diff --git a/app/src/test/java/com/papi/nova/binding/video/KotlinVideoRuntimeMigrationTest.kt b/app/src/test/java/com/papi/nova/binding/video/KotlinVideoRuntimeMigrationTest.kt index 6eee1c22..8860a5c3 100644 --- a/app/src/test/java/com/papi/nova/binding/video/KotlinVideoRuntimeMigrationTest.kt +++ b/app/src/test/java/com/papi/nova/binding/video/KotlinVideoRuntimeMigrationTest.kt @@ -140,6 +140,20 @@ class KotlinVideoRuntimeMigrationTest { MediaCodecDecoderRenderer::class.java.getMethod("getMinDecoderLatencyFullLog") } + @Test + fun rendererEmitsStructuredPerfSamplesBesideLegacyText() { + val listener = String( + Files.readAllBytes(Path.of("src/main/java/com/papi/nova/binding/video/PerfOverlayListener.kt")), + StandardCharsets.UTF_8 + ) + val renderer = readMediaCodecDecoderRendererSource() + + assertTrue(listener.contains("fun onPerfSample(sample: PerfOverlaySample)")) + assertTrue(renderer.contains("PerfOverlaySample(")) + assertTrue(renderer.contains("perfListener.onPerfSample(perfSample)")) + assertTrue(renderer.contains("perfListener.onPerfUpdate(fullLog)")) + } + @Test fun mediaCodecDecoderWatchdogUsesQuiescedRecoveryFlush() { val source = readMediaCodecDecoderRendererSource() diff --git a/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt b/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt index 67f3281c..c939f671 100644 --- a/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt +++ b/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Binder import android.os.IBinder import com.papi.nova.nvstream.http.ComputerDetails +import com.papi.nova.nvstream.http.NvHTTP import java.io.File import java.util.concurrent.ScheduledFuture import org.junit.Assert.assertEquals @@ -110,6 +111,34 @@ class KotlinComputerServiceMigrationTest { assertEquals(false, matcher.invoke(service, "", null)) } + @Test + fun parallelPollingRetriesSavedLocalAddressOnDefaultHttpPort() { + val details = ComputerDetails() + details.localAddress = ComputerDetails.AddressTuple("10.0.0.232", 49000) + + val addresses = ComputerManagerService.buildParallelPollAddresses(details) + + assertEquals(ComputerDetails.AddressTuple("10.0.0.232", 49000), addresses[0]) + assertEquals( + ComputerDetails.AddressTuple("10.0.0.232", NvHTTP.DEFAULT_HTTP_PORT), + addresses[1] + ) + } + + @Test + fun parallelPollingDoesNotDuplicateDefaultLocalAddress() { + val details = ComputerDetails() + details.localAddress = ComputerDetails.AddressTuple("10.0.0.232", NvHTTP.DEFAULT_HTTP_PORT) + + val addresses = ComputerManagerService.buildParallelPollAddresses(details) + + assertEquals(1, addresses.size) + assertEquals( + ComputerDetails.AddressTuple("10.0.0.232", NvHTTP.DEFAULT_HTTP_PORT), + addresses[0] + ) + } + @Test fun pollingTupleAndReachabilityTupleKeepJavaFieldShape() { val computer = ComputerDetails() diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt index 1ee42cba..1e57c195 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt @@ -210,6 +210,9 @@ class NovaComposeBuildConfigurationTest { generator.contains("BaselineProfileRule()") && generator.contains("includeInStartupProfile = true") && generator.contains("By.text(\"Library\")") && + generator.contains("fun libraryDetailSurface()") && + generator.contains("fun settingsSurface()") && + generator.contains("StreamSettings") && !generator.contains("com.papi.nova.ui.NovaLibraryActivity") && !generator.contains("putExtra(\"host\", \"127.0.0.1\")") ) 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 9aa0113d..264dbe6d 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt @@ -1,6 +1,9 @@ package com.papi.nova.ui import com.papi.nova.api.PolarisSessionStatus +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -176,6 +179,42 @@ class NovaHudUiStateTest { ) } + @Test + fun sparklineBufferKeepsLatestSixtySamplesInOrder() { + val buffer = NovaHudSparklineBuffer(capacity = 60) + + for (i in 1..65) { + buffer.add(i.toFloat()) + } + + val snapshot = buffer.snapshot() + assertEquals(60, snapshot.size) + assertEquals(6f, snapshot.first(), 0.01f) + assertEquals(65f, snapshot.last(), 0.01f) + } + + @Test + fun sparklineBufferCalculatesLowOnePercentWithoutMutatingSamples() { + val buffer = NovaHudSparklineBuffer(capacity = 60) + listOf(60f, 58f, 59f, 42f, 61f).forEach(buffer::add) + + assertEquals(42.0, buffer.lowOnePercent(), 0.01) + assertEquals(listOf(60f, 58f, 59f, 42f, 61f), buffer.snapshot()) + } + + @Test + fun streamHudConsumesStructuredPerfSamplesBesideTextFallback() { + val source = String( + Files.readAllBytes(Path.of("src/main/java/com/papi/nova/ui/NovaStreamHud.kt")), + StandardCharsets.UTF_8 + ) + + assertTrue(source.contains("fun updateFromPerfSample(sample: PerfOverlaySample)")) + assertTrue(source.contains("updateFps(sample.fps)")) + assertTrue(source.contains("updateFromPerfText(text: String)")) + assertTrue(source.contains("NovaHudPerfSample.fromPerfText(text)")) + } + private fun status( encoder: PolarisSessionStatus.EncoderStatus = PolarisSessionStatus.EncoderStatus( codec = "hevc_nvenc", diff --git a/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt index a817a389..795b6579 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt @@ -23,6 +23,14 @@ class NovaStreamOverlayUiStateTest { assertTrue(state.completedStages.contains("Starting compositor...")) } + @Test + fun progressStateDoesNotExposeRawIdleState() { + val state = NovaSessionProgressUiState.from("idle") + + assertEquals("Preparing session...", state.title) + assertTrue(state.completedStages.isEmpty()) + } + @Test fun progressStateUsesMessageForUnknownStage() { val state = NovaSessionProgressUiState.from("waiting_for_host", "Waiting for host") diff --git a/baselineprofile/src/main/java/com/papi/nova/baselineprofile/BaselineProfileGenerator.kt b/baselineprofile/src/main/java/com/papi/nova/baselineprofile/BaselineProfileGenerator.kt index 3bef6c26..e75a50ff 100644 --- a/baselineprofile/src/main/java/com/papi/nova/baselineprofile/BaselineProfileGenerator.kt +++ b/baselineprofile/src/main/java/com/papi/nova/baselineprofile/BaselineProfileGenerator.kt @@ -1,6 +1,7 @@ package com.papi.nova.baselineprofile import android.content.Intent +import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.uiautomator.By @@ -19,18 +20,52 @@ class BaselineProfileGenerator { packageName = PACKAGE_NAME, includeInStartupProfile = true ) { - pressHome() - startActivityAndWait() - device.waitForIdle() + launchHome() } @Test fun librarySurface() = baselineProfileRule.collect( packageName = PACKAGE_NAME + ) { + launchHome() + openLibrarySurface() + } + + @Test + fun libraryDetailSurface() = baselineProfileRule.collect( + packageName = PACKAGE_NAME + ) { + launchHome() + openLibrarySurface() + device.pressDPadCenter() + device.waitForIdle() + device.pressDPadRight() + device.pressDPadLeft() + device.pressBack() + device.waitForIdle() + } + + @Test + fun settingsSurface() = baselineProfileRule.collect( + packageName = PACKAGE_NAME ) { + pressHome() + startActivityAndWait(streamSettingsIntent()) + device.waitForIdle() + device.wait(Until.hasObject(By.textContains("Settings")), WAIT_TIMEOUT_MS) + device.pressDPadDown() + device.pressDPadDown() + device.pressDPadUp() + device.waitForIdle() + } + + private fun MacrobenchmarkScope.launchHome() { pressHome() startActivityAndWait() device.waitForIdle() + } + + private fun MacrobenchmarkScope.openLibrarySurface() { device.wait(Until.hasObject(By.text("Library")), WAIT_TIMEOUT_MS) device.pressDPadRight() device.pressDPadLeft() @@ -39,6 +74,10 @@ class BaselineProfileGenerator { device.waitForIdle() } + private fun streamSettingsIntent(): Intent { + return Intent().setClassName(PACKAGE_NAME, "$PACKAGE_NAME.preferences.StreamSettings") + } + companion object { private const val PACKAGE_NAME = "com.papi.nova" private const val WAIT_TIMEOUT_MS = 5_000L diff --git a/docs/jni_bridge_measurement.md b/docs/jni_bridge_measurement.md new file mode 100644 index 00000000..ef8ac32d --- /dev/null +++ b/docs/jni_bridge_measurement.md @@ -0,0 +1,45 @@ +# JNI Bridge Measurement + +Date: 2026-05-20 +Release target: Nova 1.1.0 + +## Candidate Calls + +Primitive-only input calls that could be measured for `@FastNative` or +`@CriticalNative`: + +- `MoonBridge.sendMouseMove` +- `MoonBridge.sendMouseButton` +- `MoonBridge.sendMultiControllerInput` +- `MoonBridge.sendControllerMotionEvent` +- `MoonBridge.sendKeyboardInput` +- `MoonBridge.sendMouseHighResScroll` +- `MoonBridge.sendMouseHighResHScroll` + +## Compatibility Rule + +Nova supports `minSdk 21`. `@CriticalNative` changes the native ABI and is not +allowed in 1.1.0 unless the measured benefit is large enough to justify explicit +registration and old-device validation. `@FastNative` is also gated because it +can delay garbage collection while native code runs. + +## 1.1.0 Decision + +Nova 1.1.0 does not apply JNI bridge annotations by default. The release ships +the HUD and Baseline Profile work first. JNI bridge annotations can follow in a +separate branch after trace evidence shows JNI transition cost is a real input +latency contributor. + +## Measurement Command + +Use a physical device and a debug build: + +```bash +./gradlew -PnovaAbis=arm64-v8a assembleNonRoot_gameDebug --console=plain +adb -s 24c12bdd install -r app/build/outputs/apk/nonRoot_game/debug/app-nonRoot_game-arm64-v8a-debug.apk +adb -s 24c12bdd logcat -c +``` + +Capture input-heavy stream behavior with Perfetto or Android Studio profiler, +then compare JNI bridge time against decoder, render, and input scheduling time. +Do not annotate JNI calls from static inspection alone. diff --git a/docs/video_baseline_evidence.md b/docs/video_baseline_evidence.md index f71e6db2..f071dfc6 100644 --- a/docs/video_baseline_evidence.md +++ b/docs/video_baseline_evidence.md @@ -4,6 +4,112 @@ This file records the measurement-only evidence pass for the Nova audit follow-up video work. It does not tune frame-drop thresholds, decoder watchdog timing, frame pacing policy, or launch-quality decisions. +## 2026-05-20 Retroid 6 1.1.0 Performance-Hardening Stream + +- Branch: `nova/1.1.0-performance-hardening` +- Base commit: `796bb1fb351cba0bfc11af54accb043a0503a841` +- APK: `app/build/outputs/apk/nonRoot_game/debug/app-nonRoot_game-arm64-v8a-debug.apk` +- Package: `com.papi.nova.debug` +- Device: Retroid Pocket 6, Android 13 +- Host: `pc-papi.lan` +- Scenario: install the ARM64 debug APK, repair Trusted Pair for the debug + package, open the Polaris library, launch Steam Big Picture, resume the + active session, enable NovaHUD from Command Center, then disconnect back to + the Nova library. + +### Build And Install + +```bash +./gradlew -PnovaAbis=x86_64 testNonRoot_gameDebugUnitTest --console=plain +git submodule update --init --recursive +./gradlew -PnovaAbis=arm64-v8a assembleNonRoot_gameDebug --console=plain +adb -s adb-24c12bdd-gitDJe._adb-tls-connect._tcp install -r app/build/outputs/apk/nonRoot_game/debug/app-nonRoot_game-arm64-v8a-debug.apk +``` + +The first ARM64 assemble attempt found the checkout had not initialized the +native `moonlight-common-c` submodule. After initializing submodules, the ARM64 +debug APK built successfully and installed over wireless ADB. + +### Stream Observations + +- Trusted Pair repaired the debug package pairing with the Polaris host. +- Polaris library loaded with `19` games and `13` recent entries. +- Steam Big Picture launched and resumed into + `com.papi.nova.debug/com.papi.nova.Game`. +- Polaris reported `v1.0.18.dirty` with AI, GameLib, AIControl, Adaptive, + Session, Devices, Lock, Cursor, and Sync enabled. +- Native stream logs reported `RTSP port: 49021`, `Starting video stream...`, + and `Received first video packet after 0 ms`. +- Decoder setup selected `c2.qti.hevc.decoder.low_latency` for hardware + decoding `video/hevc` with `width=1920`, `height=1080`, and + `frame-rate=60`. +- Command Center toggled NovaHUD from `Off` to `On` during the focused pass. + The live HUD showed host-render-limited status, `11/60` FPS, `6ms`, + `21M`, `1080p`, and `HEVC`. +- Disconnect returned to + `com.papi.nova.debug/com.papi.nova.ui.NovaLibraryActivity`. +- Disconnect logs included `Stopping video stream...`, + `Stopping control stream...`, and `ENet peer acknowledged disconnection`. + +### Sanitized HUD Summary + +The focused pass produced the HUD summary before local disconnect: + +```json +{ + "avg_fps": 11.816755746549308, + "target_fps": 60.0, + "low_1_percent_fps": 11.758942604064941, + "min_fps": 11.758942604064941, + "frame_pacing_bad_pct": 100.0, + "safe_target_fps": 30.0, + "avg_latency_ms": 6.756756756756757, + "avg_bitrate_kbps": 25000, + "packet_loss_pct": 0.0, + "codec": "HEVC", + "duration_s": 112, + "samples": 222, + "recommendation_version": 0, + "health_grade": "watch", + "primary_issue": "host_render_limited", + "issues": ["host_render_limited"], + "decoder_risk": "normal", + "hdr_risk": "normal", + "network_risk": "normal", + "capture_path": "desktop", + "safe_bitrate_kbps": 12000, + "safe_codec": "hevc", + "safe_display_mode": "headless", + "safe_hdr": false, + "relaunch_recommended": true +} +``` + +The summary omits host, device serial, unique client ID, token, and IP fields. +It contains only session metrics, health classification, codec/capture metadata, +and safe-stream recommendations. + +### Log Checks + +```bash +adb -s adb-24c12bdd-gitDJe._adb-tls-connect._tcp logcat -d > /tmp/nova-1.1.0-retroid-stream.log +adb -s adb-24c12bdd-gitDJe._adb-tls-connect._tcp logcat -b crash -d > /tmp/nova-1.1.0-retroid-crash.log +rg -i "FATAL EXCEPTION|ANR in com\\.papi\\.nova|AndroidRuntime.*FATAL" /tmp/nova-1.1.0-retroid-stream.log +rg -i "com\\.papi\\.nova|FATAL EXCEPTION|AndroidRuntime|ANR" /tmp/nova-1.1.0-retroid-crash.log +``` + +No fatal exception or ANR matches were found in the main log. The crash buffer +grep found no Nova crash entries. + +### Notes + +- The Retroid's Android launcher intercepted ADB-injected Guide/Mode chords, so + the physical Guide+Y shortcut was not used for this automated pass. NovaHUD + was enabled from Command Center through the Back-key quick-menu path. +- Disconnect is Nova's local disconnect action. The Polaris session remained + resumable in the library afterward, as expected for disconnect rather than + end-session. + ## 2026-05-17 Flip 2 Current Master HUD Summary Stream - Branch: `nova/video-current-master-evidence`