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
10 changes: 9 additions & 1 deletion docs/streamer-comparison/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
61 changes: 60 additions & 1 deletion native/opennow-core/src/cloudmatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i64>().unwrap_or_default(),
"externalAppId":null,
Expand Down Expand Up @@ -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"]),
Expand All @@ -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"]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion native/opennow-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ fn defaults() -> Map<String, Value> {
"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":"",
Expand Down Expand Up @@ -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());
Expand Down
10 changes: 9 additions & 1 deletion native/opennow-core/src/streamer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand All @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
}
6 changes: 5 additions & 1 deletion opennow-qt/cmake/Tests.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading