diff --git a/docs/streamer-comparison/video.md b/docs/streamer-comparison/video.md index c303902d0..3eca68e6b 100644 --- a/docs/streamer-comparison/video.md +++ b/docs/streamer-comparison/video.md @@ -37,10 +37,18 @@ if native { `codec: "auto"` on native requests H.264 on purpose so CloudMatch cannot pick AV1 or HEVC before local decode is known. Official Auto is wire `0` and this machine still selected HEVC. -Desktop AUTO FPS writes `0`. Core clamps fps to 30–240 with fallback 60, so AUTO is 60. +Desktop AUTO FPS writes `0`. Core resolves the request through `frame_rate::request_frame_rate`: a missing `fps` defaults to 60 and any present value clamps to the 30–360 request range, so the stored AUTO `0` clamps to 30. Resolution then caps that at 360 for 1920x1080 and 1920x1200 and 240 elsewhere, and rates above 240 additionally need an entitlement at least that high plus a confirmed hardware decoder for the selected codec, otherwise they fall back to the entitlement capped at 240, or to 240 when no entitlement is reported. `OPENNOW_NATIVE_VIDEO_BACKEND` comes from `nativeVideoBackend` and `decoderPreference`. Auto prefers D3D12 for H.264 and H.265 when the probe succeeds. AV1 auto stays on D3D11 because D3D11-on-12 would flush every frame. +`saveBandwidth` selects the NVIDIA `dynamicStreamingMode` policy requested at `session.create`. The official shared settings schema defines `0` off/do-not-adjust, `1` prefer-FPS, `2` prefer-resolution, and `3` on, and the official client passes the selected profile value into the streaming start request. OpenNOW exposes the bandwidth-saving half of that policy as one toggle and requests `1` (prefer-FPS) when it is on, matching the official Data Saver, Balanced, and Competitive profiles. Off requests `0`, so the request is unchanged. + +The request is remembered per session, not per preference. `negotiatedStreamProfile.dynamicStreamingMode` is resolved from the session's own feature bag (finalized overrides the requested echo, out-of-range falls back to unreported), and NVST ANNOUNCE reads only that. RESUME omits requested streaming features by design, so a resumed session keeps the policy it was created with, and toggling the setting mid-session cannot change the wire policy of the running session. + +At the streamer, ANNOUNCE sends `x-nv-vqos[0].dynamicStreamingMode` with that policy and `x-nv-vqos[0].dfc.adjustResAndFps` as the binary enable that follows it (`1` whenever the policy is non-zero). `x-nv-vqos[0].drc.enable` and the `resControl` attributes keep their existing values: `drc` is the separate bitrate axis, and the `resControl` attributes are not emitted by OpenNOW at all, so omitting them inherits the streamer default rather than overriding it. + +Midstream adaptation is handled locally: the decoder reports a format change, the Linux session adopts the new format and emits `BackendEvent::FormatChanged`, and `LinuxFrameProducer::ensure_slot` reallocates the Vulkan output image and framebuffer for the new dimensions. + Official persist on this machine (`sharedstorage.json` `customProfile`): - 2560×1440@120 diff --git a/locales/en.json b/locales/en.json index b19f4724a..c3712f165 100644 --- a/locales/en.json +++ b/locales/en.json @@ -163,6 +163,10 @@ "starting": "Starting the Steam store session…", "staleAccount": "The account changed. Refresh the store launch and try again." }, + "saveBandwidth": { + "title": "Save bandwidth", + "description": "Lets the server trade resolution and image quality for a steadier frame rate when your connection cannot sustain the selected profile. Off requests no dynamic adjustment. Applies to new sessions." + }, "graphicsProcessor": { "title": "Graphics processor", "restartHint": "Uses the same GPU for decoding and display. Changes apply after restarting OpenNOW.", diff --git a/native/opennow-core/src/cloudmatch.rs b/native/opennow-core/src/cloudmatch.rs index ea56c5698..f467be179 100644 --- a/native/opennow-core/src/cloudmatch.rs +++ b/native/opennow-core/src/cloudmatch.rs @@ -1368,7 +1368,7 @@ fn build_create_body(app_id: &str, params: &Value, settings: &Value, device_id: features["hidDevices"] = Value::Null; features["qosPolicy"] = json!(0); features["touchSupport"] = json!(false); - features["dynamicStreamingMode"] = json!(0); + features["dynamicStreamingMode"] = json!(dynamic_streaming_mode(settings)); json!({"sessionRequestData":{ "appId":app_id.parse::().unwrap_or_default(), "externalAppId":null, @@ -1691,6 +1691,8 @@ fn negotiated_profile(monitor: &Value, features: &Value) -> Value { _ => None, }); let color = profile_color(&json!(bit_depth), &json!(chroma)); + let dynamic_streaming_mode = + value_i64(&features["dynamicStreamingMode"]).filter(|value| (0..=3).contains(value)); json!({ "resolution":resolution, "fps":value_i64(&monitor["framesPerSecond"]), @@ -1700,6 +1702,7 @@ fn negotiated_profile(monitor: &Value, features: &Value) -> Value { "chromaFormat":chroma, "bitDepthSource":if features.get("bitDepth").is_some() { "request" } else { "unreported" }, "chromaFormatSource":if features.get("chromaFormat").is_some() { "request" } else { "unreported" }, + "dynamicStreamingMode":dynamic_streaming_mode, "enableL4S":features["enabledL4S"], "enableCloudGsync":features["cloudGsync"], "enableReflex":features["reflex"] @@ -2262,6 +2265,10 @@ fn setting_bool(settings: &Value, key: &str, fallback: bool) -> bool { settings[key].as_bool().unwrap_or(fallback) } +fn dynamic_streaming_mode(settings: &Value) -> u8 { + u8::from(setting_bool(settings, "saveBandwidth", false)) +} + fn resolved_cloud_gsync(settings: &Value) -> bool { match settings["nativeCloudGsyncMode"].as_str().unwrap_or("auto") { "disabled" => false, @@ -4295,6 +4302,58 @@ mod tests { assert_eq!(features["chromaFormat"], 0); } + #[test] + fn bandwidth_saving_requests_the_prefer_fps_dynamic_quality_policy() { + for (saved, mode) in [(None, 0), (Some(false), 0), (Some(true), 1)] { + let mut settings = json!({ + "resolution":"1920x1080", + "fps":60, + "maxBitrateMbps":75 + }); + if let Some(saved) = saved { + settings["saveBandwidth"] = json!(saved); + } + let body = build_create_body( + "12345", + &json!({"title":"Portal 2"}), + &settings, + "device-id", + ); + assert_eq!( + body["sessionRequestData"]["requestedStreamingFeatures"]["dynamicStreamingMode"], + mode + ); + } + } + + #[test] + fn negotiated_profile_carries_the_session_dynamic_quality_policy() { + let base = trusted_cloudmatch_base(DEFAULT_STREAMING_BASE).unwrap(); + let parse = |session| { + session_info(&json!({"session":session}), &base, "", "123", "device").unwrap() + }; + for (echoed, finalized, mode) in [ + (None, None, Value::Null), + (Some(1), None, json!(1)), + (Some(1), Some(0), json!(0)), + (None, Some(3), json!(3)), + (Some(7), None, Value::Null), + ] { + let mut session = json!({"sessionId":"seat","status":2}); + if let Some(echoed) = echoed { + session["sessionRequestData"]["requestedStreamingFeatures"]["dynamicStreamingMode"] = + json!(echoed); + } + if let Some(finalized) = finalized { + session["finalizedStreamingFeatures"]["dynamicStreamingMode"] = json!(finalized); + } + assert_eq!( + parse(session)["negotiatedStreamProfile"]["dynamicStreamingMode"], + mode + ); + } + } + #[test] fn manual_h265_preserves_codec_and_ten_bit_color_on_native_nvst() { let body = build_create_body( diff --git a/native/opennow-core/src/settings.rs b/native/opennow-core/src/settings.rs index 99d06a621..1766364f4 100644 --- a/native/opennow-core/src/settings.rs +++ b/native/opennow-core/src/settings.rs @@ -893,7 +893,7 @@ fn defaults() -> Map { "onboardingCompleted":false, "resolution":"1920x1080", "aspectRatio":"16:9", "posterSizeScale":1.05, "fps":60, "frameGeneration":"off", "upscaling":"off", "upscalingSharpness":10, "upscalingDenoise":0, - "maxBitrateMbps":75, "recordingBitrateMbps":null, + "maxBitrateMbps":75, "saveBandwidth":false, "recordingBitrateMbps":null, "recordingResolution":"720p", "recordingFps":30, "streamClientMode":"native", "replayBufferEnabled":false, "replayBufferSeconds":30, "replayBufferMemoryMiB":256, "nativeVideoBackend":"auto", "nativeStreamerExecutablePath":"", "audioOutputDevice":"", @@ -2175,9 +2175,22 @@ mod tests { store.set("reducedMotion", json!(true)).unwrap(), json!(true) ); + assert_eq!( + store.set("saveBandwidth", json!(true)).unwrap(), + json!(true) + ); + assert_eq!( + store.set("saveBandwidth", json!("yes")).unwrap(), + json!(false) + ); + assert_eq!( + store.set("saveBandwidth", json!(true)).unwrap(), + json!(true) + ); let loaded = SettingsStore::load(Some(directory.clone())).unwrap(); assert_eq!(loaded.all()["fps"], json!(240)); assert_eq!(loaded.all()["maxBitrateMbps"], json!(200)); + assert_eq!(loaded.all()["saveBandwidth"], json!(true)); assert_eq!(loaded.all()["launchInConsoleMode"], json!(false)); assert_eq!(loaded.all()["reducedMotion"], json!(true)); assert!(store.set("notASetting", json!(true)).is_err()); diff --git a/native/opennow-core/src/streamer.rs b/native/opennow-core/src/streamer.rs index d8ab56703..7c486604f 100644 --- a/native/opennow-core/src/streamer.rs +++ b/native/opennow-core/src/streamer.rs @@ -2769,7 +2769,11 @@ mod tests { "session": { "sessionId": "session-one", "status": 2, - "signalingUrl": "wss://server.nvidiagrid.net/nvst/" + "signalingUrl": "wss://server.nvidiagrid.net/nvst/", + "negotiatedStreamProfile": { + "codec": "H264", + "dynamicStreamingMode": 1 + } } }), &json!({ @@ -2786,6 +2790,10 @@ mod tests { assert_eq!(prepared["context"]["settings"]["codec"], "H264"); assert_eq!(prepared["context"]["settings"]["transportMode"], "nvst"); assert_eq!(prepared["context"]["settings"]["maxBitrateMbps"], 200); + assert_eq!( + prepared["context"]["session"]["negotiatedStreamProfile"]["dynamicStreamingMode"], + 1 + ); assert_eq!(prepared["context"]["surface"], Value::Null); assert!(service.worker.lock().expect("streamer worker").is_none()); } diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs index 8a24050e0..3acc13c85 100644 --- a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs @@ -835,6 +835,8 @@ fn build_announce(context: &SessionContext, params: AnnounceParams<'_>) -> Strin 0 }; let (bit_depth, chroma_format) = negotiated_color_format(context, &codec); + let dynamic_streaming_mode = negotiated_dynamic_streaming_mode(context); + let adjust_res_and_fps = negotiated_adjustment_enabled(dynamic_streaming_mode); let mut lines = vec![ "v=0".to_owned(), "o=unknown 0 14 IN IPv4 127.0.0.1".to_owned(), @@ -887,13 +889,13 @@ fn build_announce(context: &SessionContext, params: AnnounceParams<'_>) -> Strin "a=x-nv-vqos[0].bllFec.enable:0".to_owned(), "a=x-nv-vqos[0].grc.enable:7".to_owned(), "a=x-nv-vqos[0].drc.enable:0".to_owned(), - "a=x-nv-vqos[0].dfc.adjustResAndFps:0".to_owned(), + format!("a=x-nv-vqos[0].dfc.adjustResAndFps:{adjust_res_and_fps}"), "a=x-nv-vqos[0].calculateAvgVideoStreamingBitrate:1".to_owned(), format!("a=x-nv-vqos[0].bw.maximumBitrateKbps:{bitrate}"), "a=x-nv-vqos[0].bw.minimumBitrateKbps:1000".to_owned(), "a=x-nv-vqos[0].drc.bitrateIirFilterFactor:128".to_owned(), "a=x-nv-vqos[0].resControl.bitrateIirFilterFactor:128".to_owned(), - "a=x-nv-vqos[0].dynamicStreamingMode:0".to_owned(), + format!("a=x-nv-vqos[0].dynamicStreamingMode:{dynamic_streaming_mode}"), "a=x-nv-packetPacing.version:3".to_owned(), "a=x-nv-packetPacing.mode:1".to_owned(), "a=x-nv-packetPacing.numGroups:5".to_owned(), @@ -1041,6 +1043,22 @@ fn negotiated_codec(context: &SessionContext) -> String { .to_ascii_uppercase() } +fn negotiated_dynamic_streaming_mode(context: &SessionContext) -> u8 { + context + .session + .extra + .get("negotiatedStreamProfile") + .and_then(|profile| profile.get("dynamicStreamingMode")) + .and_then(Value::as_u64) + .and_then(|value| u8::try_from(value).ok()) + .filter(|value| *value <= 3) + .unwrap_or(0) +} + +fn negotiated_adjustment_enabled(policy: u8) -> u8 { + u8::from(policy != 0) +} + fn advertised_srtp_profile<'a>(response: &'a RtspResponse, sdp: &'a str) -> Option<&'a str> { const PROFILES: [&str; 8] = [ "AEAD_AES_128_GCM_8", @@ -1587,6 +1605,43 @@ mod tests { } } + #[test] + fn announce_uses_the_negotiated_dynamic_quality_policy_not_the_saved_preference() { + for (profile, policy, adjust) in [ + (None, 0, 0), + (Some(1), 1, 1), + (Some(2), 2, 1), + (Some(3), 3, 1), + (Some(7), 0, 0), + ] { + let mut value = context(); + value.settings["saveBandwidth"] = json!(true); + if let Some(profile) = profile { + value.session.extra["negotiatedStreamProfile"]["dynamicStreamingMode"] = + json!(profile); + } + let sdp = build_announce( + &value, + AnnounceParams { + key: &"01".repeat(32), + key_id: 7, + port: 49006, + address: "192.0.2.10", + ufrag: "abcd", + password: "abcdefghijklmnopqrstuv", + fingerprint: "AA:BB", + video_port: 5004, + video_packet_size: 1280, + rtcp_on_sctp: true, + microphone_available: false, + }, + ); + assert!(sdp.contains(&format!("a=x-nv-vqos[0].dynamicStreamingMode:{policy}\r\n"))); + assert!(sdp.contains(&format!("a=x-nv-vqos[0].dfc.adjustResAndFps:{adjust}\r\n"))); + assert!(sdp.contains("a=x-nv-vqos[0].drc.enable:0\r\n")); + } + } + #[test] fn announce_dynamic_range_follows_accepted_hdr_not_saved_intent() { for (accepted, requested, mode) in [ diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs index 4798d619f..2d1cc13d5 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs @@ -1825,4 +1825,95 @@ mod tests { } } } + + #[test] + #[ignore = "requires the FFmpeg CLI with libx264; run under --features linux-ffmpeg-bundled"] + fn ffmpeg_software_decoder_reports_a_midstream_resolution_change() { + let encode = |width: u32, height: u32| { + let output = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + &format!("testsrc=size={width}x{height}:rate=30"), + "-frames:v", + "1", + "-c:v", + "libx264", + "-tune", + "zerolatency", + "-pix_fmt", + "yuv420p", + "-f", + "h264", + "pipe:1", + ]) + .output() + .expect("FFmpeg CLI must start"); + assert!( + output.status.success(), + "sample encode failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!output.stdout.is_empty()); + output.stdout + }; + + initialize_ffmpeg().unwrap(); + let initial = encode(256, 144); + let resized = encode(320, 180); + let mut decoder = FfmpegDecoder::open( + VideoCodec::H264, + StreamFormat::video_default(256, 144).unwrap(), + FfmpegMode::Software, + ) + .unwrap(); + + let mut observed = Vec::new(); + let mut changes = Vec::new(); + for (payload, timestamp) in [(initial, 1_u64), (resized, 2)] { + let packet = EncodedVideoFrame::new(payload, timestamp, true).unwrap(); + observed.extend( + decoder + .decode(&packet) + .unwrap() + .into_iter() + .map(|frame| (frame.format.width, frame.format.height)), + ); + changes.extend( + decoder + .take_format_change() + .map(|format| (format.width, format.height)), + ); + } + observed.extend( + decoder + .flush() + .unwrap() + .into_iter() + .map(|frame| (frame.format.width, frame.format.height)), + ); + changes.extend( + decoder + .take_format_change() + .map(|format| (format.width, format.height)), + ); + + assert!( + observed.contains(&(256, 144)), + "the first packet must decode at its own size: {observed:?}" + ); + assert!( + observed.contains(&(320, 180)), + "the resized packet must decode at the new size: {observed:?}" + ); + assert_eq!( + changes, + vec![(320, 180)], + "the decoder must report exactly the resized format as a change" + ); + } } diff --git a/opennow-qt/cmake/Tests.cmake b/opennow-qt/cmake/Tests.cmake index 06d30f289..ffac9764b 100644 --- a/opennow-qt/cmake/Tests.cmake +++ b/opennow-qt/cmake/Tests.cmake @@ -393,7 +393,7 @@ if(BUILD_TESTING) set_tests_properties(qml-push-invalidation-${width} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) endforeach() qt_add_resources(opennow-qt "store-paging-acceptance" - PREFIX "/acceptance" BASE tests FILES tests/RegionPingAcceptance.qml tests/RegionChoicesAcceptance.qml tests/StorePagingAcceptance.qml tests/BackendAvailabilityAcceptance.qml tests/StreamRecoveryAcceptance.qml tests/IdleModeAcceptance.qml tests/FrameGenerationAcceptance.qml tests/AudioOutputAcceptance.qml tests/CollectionsAcceptance.qml tests/SteamBigPictureAcceptance.qml tests/PersistentInGameSettingsAcceptance.qml tests/StoreLaunchAcceptance.qml tests/ControllerMetadataAcceptance.qml tests/MicrophoneAcceptance.qml tests/RecordingAcceptance.qml) + PREFIX "/acceptance" BASE tests FILES tests/RegionPingAcceptance.qml tests/RegionChoicesAcceptance.qml tests/StorePagingAcceptance.qml tests/BackendAvailabilityAcceptance.qml tests/StreamRecoveryAcceptance.qml tests/IdleModeAcceptance.qml tests/FrameGenerationAcceptance.qml tests/AudioOutputAcceptance.qml tests/CollectionsAcceptance.qml tests/SteamBigPictureAcceptance.qml tests/PersistentInGameSettingsAcceptance.qml tests/SaveBandwidthAcceptance.qml tests/StoreLaunchAcceptance.qml tests/ControllerMetadataAcceptance.qml tests/MicrophoneAcceptance.qml tests/RecordingAcceptance.qml) add_test(NAME qml-recording COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop --route settings --smoke-recording --reduced-motion) @@ -526,6 +526,10 @@ if(BUILD_TESTING) COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop --route settings-streaming --smoke-persistent-in-game-settings --reduced-motion) set_tests_properties(qml-persistent-in-game-settings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 10) + add_test(NAME qml-save-bandwidth + COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop + --route settings-streaming --smoke-save-bandwidth --reduced-motion) + set_tests_properties(qml-save-bandwidth PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 10) foreach(width 960 1600) foreach(mode windowed fullscreen) set(store_launch_args) diff --git a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml index 3dfc0b1cc..2f2d6f71d 100644 --- a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml +++ b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml @@ -197,6 +197,16 @@ Column { onCommitted: value => page.settingsScreen.setSetting("maxBitrateMbps",Math.round(value)) } } + DesktopSettingsRow { + width: parent.width; paperStyle: true; glyph: "drop"; title: qsTr("Save bandwidth") + description: qsTr("Lets the server trade resolution and image quality for a steadier frame rate when your connection cannot sustain the selected profile. Off requests no dynamic adjustment. Applies to new sessions.") + DesktopSettingsToggle { + objectName: "saveBandwidthToggle" + checked: page.settingsScreen.boolSetting("saveBandwidth", false) + Accessible.name: qsTr("Save bandwidth") + onValueChangedByUser: value => page.settingsScreen.setSetting("saveBandwidth", value) + } + } DesktopSettingsRow { width: parent.width; paperStyle: true; glyph: "bolt"; title: qsTr("Reflex low latency") description: qsTr("When the game supports it"); showDivider: false diff --git a/opennow-qt/qml/screens/SettingsScreen.qml b/opennow-qt/qml/screens/SettingsScreen.qml index 0a12ae067..90a3602c8 100644 --- a/opennow-qt/qml/screens/SettingsScreen.qml +++ b/opennow-qt/qml/screens/SettingsScreen.qml @@ -301,6 +301,7 @@ FocusScope { descriptorChoice(qsTr("Color quality"), ShellStore.settingsOwnerState.colorDescription, "colorQuality", ShellStore.settingsOwnerState.colorQualityItems), {t:qsTr("HDR"), d:hdrDescription, v:Boolean(settings.enableHdr) ? qsTr("On") : qsTr("Off"), key:"enableHdr", values:[false,true], labels:[qsTr("Off"),qsTr("On")], control:"segments", selectedIndex:Boolean(settings.enableHdr) ? 1 : 0, disabledValues:hdrAvailable ? [] : [true]}, {t:"Max bitrate", d:"Maximum requested stream bitrate", v:Number(settings.maxBitrateMbps || 75) + " Mbps", key:"maxBitrateMbps", values:[25,50,75,100,150,200], labels:["25 Mbps","50 Mbps","75 Mbps","100 Mbps","150 Mbps","200 Mbps"], control:"slider", sliderPercent:Number(settings.maxBitrateMbps || 75) / 106}, + toggle(qsTr("Save bandwidth"), qsTr("Lets the server trade resolution and image quality for a steadier frame rate when your connection cannot sustain the selected profile. Off requests no dynamic adjustment. Applies to new sessions."), "saveBandwidth"), {t:qsTr("Frame generation (Experimental)"), d:qsTr("Targets 120 displayed FPS from a 60 FPS stream. Requires a fast GPU and 120 Hz display; adds latency and artifacts."), v:frameGeneration ? qsTr("2×") : qsTr("Off"), key:"frameGeneration", values:["off","2x"], labels:[qsTr("Off"),qsTr("2×")], control:"segments", selectedIndex:frameGeneration ? 1 : 0}, choice(qsTr("Upscaling"), Qt.platform.os === "osx" ? qsTr("Spatial upscaling for enlarged video. Uses extra GPU time; falls back to normal scaling when MetalFX is unavailable.") diff --git a/opennow-qt/src/acceptance/SmokeAcceptance.cpp b/opennow-qt/src/acceptance/SmokeAcceptance.cpp index 2eed192f4..5b699258c 100644 --- a/opennow-qt/src/acceptance/SmokeAcceptance.cpp +++ b/opennow-qt/src/acceptance/SmokeAcceptance.cpp @@ -236,6 +236,7 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-collections"_s) || m_arguments.contains(u"--smoke-steam-big-picture"_s) || m_arguments.contains(u"--smoke-persistent-in-game-settings"_s) + || m_arguments.contains(u"--smoke-save-bandwidth"_s) || m_arguments.contains(u"--smoke-store-launch"_s) || m_arguments.contains(u"--smoke-idle-mode"_s) || m_arguments.contains(u"--smoke-queue-drops"_s) @@ -269,6 +270,8 @@ int AcceptanceSession::startSmokeWorkload() ? u"qrc:/acceptance/SteamBigPictureAcceptance.qml"_s : m_arguments.contains(u"--smoke-persistent-in-game-settings"_s) ? u"qrc:/acceptance/PersistentInGameSettingsAcceptance.qml"_s + : m_arguments.contains(u"--smoke-save-bandwidth"_s) + ? u"qrc:/acceptance/SaveBandwidthAcceptance.qml"_s : m_arguments.contains(u"--smoke-store-launch"_s) ? u"qrc:/acceptance/StoreLaunchAcceptance.qml"_s : m_arguments.contains(u"--smoke-idle-mode"_s) @@ -297,6 +300,7 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-collections"_s) || m_arguments.contains(u"--smoke-steam-big-picture"_s) || m_arguments.contains(u"--smoke-persistent-in-game-settings"_s) + || m_arguments.contains(u"--smoke-save-bandwidth"_s) || m_arguments.contains(u"--smoke-store-launch"_s)) { auto *client = fixture->property("client").value(); if (!client) return EXIT_FAILURE; diff --git a/opennow-qt/tests/SaveBandwidthAcceptance.qml b/opennow-qt/tests/SaveBandwidthAcceptance.qml new file mode 100644 index 000000000..26d734770 --- /dev/null +++ b/opennow-qt/tests/SaveBandwidthAcceptance.qml @@ -0,0 +1,67 @@ +import QtQuick +import OpenNOW + +QtObject { + property Component consoleSettings: Component { SettingsScreen { visible: false; selectedSection: 1 } } + property QtObject client: QtObject { + property string state: "ready" + property string lastError: "" + property var calls: [] + signal responseReceived(string requestId, var result) + signal requestFailed(string requestId, string code, string message) + signal eventReceived(string name, var payload) + function markUiReady() {} + function logShellDiagnostic(message) {} + function request(method, params, timeout) { + const id = "fixture-" + (calls.length + 1) + calls = calls.concat([{id:id, method:method, params:params}]) + return id + } + function cancel(id) { return true } + } + function check(ok, message) { if (!ok) throw new Error("Save bandwidth: " + message) } + function find(item, name) { + if (item.objectName === name) return item + for (const child of item.children || []) { + const found = find(child, name) + if (found) return found + } + return null + } + function run(parent) { + ShellStore.settings = Object.assign({}, ShellStore.settings, {resolution: "1920x1080", fps: 60}) + const toggle = find(parent, "saveBandwidthToggle") + check(toggle, "desktop exposes the bandwidth-saving toggle") + check(!toggle.checked, "desktop preference defaults off, so fixed quality stays the wire default") + ShellStore.settings = Object.assign({}, ShellStore.settings, {saveBandwidth:true}) + const consolePage = consoleSettings.createObject(parent) + const consoleRow = () => consolePage.settingsModel().find(item => item.key === "saveBandwidth") + check(consoleRow() && consoleRow().toggle && consoleRow().v === "On" + && consoleRow().t === "Save bandwidth", "console exposes the same preference") + for (const enabled of [false, true]) { + toggle.clicked() + const write = client.calls[client.calls.length - 1] + check(write.method === "settings.set" && write.params.key === "saveBandwidth" + && write.params.value === enabled, "desktop requests the persisted preference") + client.eventReceived("settings.changed", {key:"saveBandwidth", value:enabled}) + check(toggle.checked === enabled && ShellStore.settings.saveBandwidth === enabled, + "desktop reflects the saved value") + check(consoleRow().v === (enabled ? "On" : "Off"), "console reflects desktop changes") + } + consolePage.activate(consoleRow()) + let write = client.calls[client.calls.length - 1] + check(write.method === "settings.set" && write.params.key === "saveBandwidth" + && write.params.value === false, "console writes the same preference") + client.eventReceived("settings.changed", {key:"saveBandwidth", value:false}) + check(!toggle.checked, "desktop reflects console changes") + consolePage.activate(consoleRow()) + write = client.calls[client.calls.length - 1] + check(write.method === "settings.set" && write.params.key === "saveBandwidth" + && write.params.value === true, "console writes the preference back on") + client.eventReceived("settings.changed", {key:"saveBandwidth", value:true}) + check(toggle.checked && consoleRow().v === "On", + "both surfaces agree once the preference is enabled") + consolePage.destroy() + return true + } +}