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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ GreenOvercast is experimental.
| Select + Start | Exit after holding for one second | Exit after holding for one second |

Settings include Xbox/Nintendo face-button layouts, game artwork, and Sign
out. Games that return a 16:9 stream remain letterboxed on 4:3 displays.
out. GeForce NOW also has target framerate and maximum bitrate settings.
Games that return a 16:9 stream remain letterboxed on 4:3 displays.

GeForce NOW uses the same gamepad controls. For store dialogs and game
launchers, press Select + Y to toggle mouse mode: D-pad or left stick moves the
Expand Down
23 changes: 20 additions & 3 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ const release_zig_roots = [_]struct {
.{
.name = "greenovercast-ui",
.path = "src/ui/handheld_ui.zig",
.imports = &.{.{ .name = "catalog_search", .path = "src/catalog/catalog_search.zig" }},
.imports = &.{
.{ .name = "catalog_search", .path = "src/catalog/catalog_search.zig" },
.{ .name = "gfn_stream_preferences", .path = "src/provider/geforce_now/stream_preferences.zig" },
},
},
.{ .name = "greenovercast-sdl-platform", .path = "src/platform/sdl_platform.zig" },
.{
Expand Down Expand Up @@ -605,9 +608,22 @@ pub fn build(b: *std.Build) void {
.{ .source = "src/input/guide_chord.zig" },
.{ .source = "src/session/message_protocol.zig" },
.{ .source = "src/ui/keyboard.zig" },
.{ .source = "src/ui/control_icons.zig" },
.{
.source = "src/ui/control_icons.zig",
.imports = &.{.{ .name = "gfn_stream_preferences", .path = "src/provider/geforce_now/stream_preferences.zig" }},
},
.{ .source = "src/ui/navigation_repeat.zig" },
.{ .source = "src/ui/persistent_settings.zig" },
.{
.source = "src/ui/persistent_settings.zig",
.imports = &.{.{ .name = "gfn_stream_preferences", .path = "src/provider/geforce_now/stream_preferences.zig" }},
},
.{
.source = "src/ui/settings_view.zig",
.project_includes = true,
.sdl = true,
.imports = &.{.{ .name = "gfn_stream_preferences", .path = "src/provider/geforce_now/stream_preferences.zig" }},
},
.{ .source = "src/provider/geforce_now/stream_preferences.zig" },
.{ .source = "src/ui/stream_dimensions.zig" },
.{ .source = "src/media/rtp/h264_depacketizer.zig" },
.{ .source = "src/net/json_reader.zig" },
Expand Down Expand Up @@ -670,6 +686,7 @@ pub fn build(b: *std.Build) void {
.source = "src/provider/geforce_now/webrtc_session.zig",
.project_includes = true,
.sdl = true,
.imports = &.{.{ .name = "gfn_video_feedback_fake", .path = "tests/gfn_video_feedback_fake.zig" }},
},
};
for (tests) |test_config|
Expand Down
3 changes: 2 additions & 1 deletion packaging/portmaster/greenovercast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ The release requires glibc 2.38 or newer. ArkOS is not supported.
| Select + Start | Exit after holding for one second | Exit after holding for one second |

Settings include Xbox/Nintendo face-button layouts, game artwork, and Sign
out. Games that return a 16:9 stream remain letterboxed on 4:3 displays.
out. GeForce NOW also has target framerate and maximum bitrate settings.
Games that return a 16:9 stream remain letterboxed on 4:3 displays.

GeForce NOW uses the same gamepad controls. For store dialogs and game
launchers, press Select + Y to toggle mouse mode: D-pad or left stick moves the
Expand Down
5 changes: 5 additions & 0 deletions src/app/release.zig
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,10 @@ pub const Release = struct {
std.mem.sliceTo(&self.title_name, 0),
requested_width,
requested_height,
.{
.frames_per_second = @intCast(c.go_handheld_ui_gfn_frames_per_second(self.ui())),
.max_bitrate_kbps = c.go_handheld_ui_gfn_max_bitrate_kbps(self.ui()),
},
) catch |err| {
if (err == error.Cancelled) return .cancelled;
std.debug.print("GeForce NOW session creation failed: {s}\n", .{@errorName(err)});
Expand Down Expand Up @@ -577,6 +581,7 @@ pub const Release = struct {
self.geforce_cloud.?.streamWidth(),
self.geforce_cloud.?.streamHeight(),
self.geforce_cloud.?.streamFramesPerSecond(),
self.geforce_cloud.?.stream_preferences.max_bitrate_kbps,
) catch return .failed;
self.geforce_webrtc.?.setup() catch |err| {
if (err == error.Cancelled) return .cancelled;
Expand Down
105 changes: 100 additions & 5 deletions src/provider/geforce_now/session_client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const subscription = @import("subscription_protocol.zig");
const uuid = @import("uuid");
const ads = @import("ad_protocol.zig");

pub const StreamPreferences = @import("stream_preferences.zig").Preferences;

pub const AdHandler = struct {
context: *anyopaque,
run: *const fn (*anyopaque, *Client, ads.Opportunity, u8) anyerror!void,
Expand All @@ -23,7 +25,6 @@ const user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36

const fallback_stream_width: u16 = 1024;
const fallback_stream_height: u16 = 768;
const preferred_frames_per_second: u16 = 30;
const maximum_stream_width: u16 = 1280;
const maximum_stream_height: u16 = 768;
const maximum_bearer_length = 64 * 1024;
Expand All @@ -39,7 +40,8 @@ pub const Client = struct {
last_response_retryable: ?bool = null,
stream_width: u16 = fallback_stream_width,
stream_height: u16 = fallback_stream_height,
stream_frames_per_second: u16 = preferred_frames_per_second,
stream_preferences: StreamPreferences = .{},
stream_frames_per_second: u16 = (StreamPreferences{}).frames_per_second,
ad_handler: ?AdHandler = null,
ad_stop: ?*std.atomic.Value(bool) = null,
completed_ads: std.ArrayListUnmanaged([]u8) = .{},
Expand Down Expand Up @@ -76,8 +78,11 @@ pub const Client = struct {
internal_title: []const u8,
width: u16,
height: u16,
preferences: StreamPreferences,
) !void {
if (self.session != null) return error.SessionAlreadyActive;
try preferences.validate();
self.stream_preferences = preferences;
self.clearAds();
uuid.generate(&self.client_id);
if (self.app_id) |id| self.allocator.free(id);
Expand Down Expand Up @@ -406,7 +411,7 @@ pub const Client = struct {
) !void {
self.stream_width = fallback_stream_width;
self.stream_height = fallback_stream_height;
self.stream_frames_per_second = preferred_frames_per_second;
self.stream_frames_per_second = self.stream_preferences.frames_per_second;
const summary = self.fetchSubscriptionStatus(provider_url, bearer) catch |err| {
if (err == error.Cancelled) return err;
std.debug.print("GeForce NOW membership check unavailable: {s}\n", .{@errorName(err)});
Expand All @@ -427,21 +432,22 @@ pub const Client = struct {
if (summary.bestForDisplayWithin(
display_width,
display_height,
preferred_frames_per_second,
self.stream_preferences.frames_per_second,
maximum_stream_width,
maximum_stream_height,
)) |selected| {
self.stream_width = selected.width;
self.stream_height = selected.height;
self.stream_frames_per_second = selected.frames_per_second;
std.debug.print(
"GeForce NOW stream mode: {d}x{d}@{d} for {d}x{d} display\n",
"GeForce NOW stream mode: {d}x{d}@{d} for {d}x{d} display, max bitrate {d} kbps\n",
.{
selected.width,
selected.height,
selected.frames_per_second,
display_width,
display_height,
self.stream_preferences.max_bitrate_kbps,
},
);
}
Expand Down Expand Up @@ -838,3 +844,92 @@ test "ad reports require a positive CloudMatch acknowledgement" {
http.replyJson("{\"requestStatus\":{\"statusCode\":1}}");
try client.reportAd("pod", update);
}

test "each stream snapshots preferences changed after provider creation" {
const http = @import("gfn_http_fake");
http.reset();
defer http.reset();
http.cancel_on_poll = std.math.maxInt(usize);
const Handler = struct {
var user_info = "{\"sub\":\"test-user\",\"age\":30}".*;

fn respond(method: []const u8, url: []const u8) void {
if (std.mem.eql(u8, method, "DELETE")) {
http.replyNoContent();
} else if (std.mem.endsWith(u8, url, "/userinfo")) {
user_info = "{\"sub\":\"test-user\",\"age\":30}".*;
http.replyJson(&user_info);
} else if (std.mem.endsWith(u8, url, "/v2/serverInfo")) {
http.replyJson("{\"requestStatus\":{\"statusCode\":1,\"serverId\":\"TEST\"}}");
} else if (std.mem.indexOf(u8, url, "/v4/subscriptions?") != null) {
http.replyJson(
\\{"features":{"resolutions":[
\\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":30,"isEntitled":true},
\\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":60,"isEntitled":true}
\\]}}
);
} else if (std.mem.eql(u8, method, "POST")) {
http.replyJson("{\"requestStatus\":{\"statusCode\":1},\"session\":{\"sessionId\":\"test-session\",\"status\":1}}");
} else http.response = null;
}
};
http.on_request = Handler.respond;
var auth = http.authClient(auth_client.Client);
@memcpy(auth.device_id[0..36], "00000000-0000-4000-8000-000000000001");
auth.provider = try @import("provider_protocol.zig").parsePreferredProvider(std.testing.allocator,
\\{"gfnServiceInfo":{"gfnServiceEndpoints":[{"loginProviderCode":"NVIDIA","loginProviderDisplayName":"NVIDIA","idpId":"test","streamingServiceUrl":"https://np.example.nvidiagrid.net/"}]}}
);
defer auth.provider.?.deinit();
const client = try Client.create(std.testing.allocator, &auth, @ptrCast(auth.ui));
defer client.destroy();

var preferences = StreamPreferences{};
for ([_]StreamPreferences{
.{ .frames_per_second = 60, .max_bitrate_kbps = 16000 },
.{ .frames_per_second = 30, .max_bitrate_kbps = 4000 },
}) |changed| {
preferences = changed;
const before = http.requests;
try client.start("12345", "Test game", 640, 480, preferences);
try std.testing.expectEqual(@as(usize, 5), http.requests - before);
try std.testing.expectEqual(preferences.frames_per_second, client.streamFramesPerSecond());
try std.testing.expectEqualDeep(preferences, client.stream_preferences);
const body = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, http.last_body[0..http.last_body_length], .{});
defer body.deinit();
const monitors = body.value.object.get("sessionRequestData").?.object.get("clientRequestMonitorSettings").?.array;
try std.testing.expectEqual(@as(i64, preferences.frames_per_second), monitors.items[0].object.get("framesPerSecond").?.integer);

preferences.max_bitrate_kbps = 20000;
try std.testing.expectEqual(changed.max_bitrate_kbps, client.stream_preferences.max_bitrate_kbps);
try client.stop();
}
}

test "stream start rejects invalid preferences before network requests" {
const http = @import("gfn_http_fake");
http.reset();
var auth = http.authClient(auth_client.Client);
const client = try Client.create(std.testing.allocator, &auth, @ptrCast(auth.ui));
defer client.destroy();
try std.testing.expectError(error.InvalidStreamPreferences, client.start("12345", "Test", 640, 480, .{ .frames_per_second = 1 }));
try std.testing.expectError(error.InvalidStreamPreferences, client.start("12345", "Test", 640, 480, .{ .max_bitrate_kbps = std.math.maxInt(u32) }));
try std.testing.expectEqual(@as(usize, 0), http.requests);
}

test "entitlement fallback does not overwrite requested preferences" {
const http = @import("gfn_http_fake");
http.reset();
defer http.reset();
http.cancel_on_poll = std.math.maxInt(usize);
http.replyJson(
\\{"requestStatus":{"statusCode":1,"serverId":"TEST"},"features":{"resolutions":[
\\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":30,"isEntitled":true}
\\]}}
);
var auth = http.authClient(auth_client.Client);
var client = Client{ .allocator = std.testing.allocator, .auth = &auth, .ui = @ptrCast(auth.ui), .client_id = [_]u8{0} ** 37, .stream_preferences = .{ .frames_per_second = 60, .max_bitrate_kbps = 12000 } };
try client.selectStreamMode("https://np.example.nvidiagrid.net/", auth.bearer().?, 640, 480);
try std.testing.expectEqual(@as(usize, 2), http.requests);
try std.testing.expectEqual(@as(u16, 30), client.streamFramesPerSecond());
try std.testing.expectEqual(@as(u16, 60), client.stream_preferences.frames_per_second);
}
38 changes: 38 additions & 0 deletions src/provider/geforce_now/stream_preferences.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const std = @import("std");

pub const frames_per_second_choices = [_]u16{ 30, 60 };
pub const bitrate_choices_kbps = [_]u32{ 4000, 6000, 8000, 12000, 16000, 20000 };

pub const Preferences = struct {
frames_per_second: u16 = 30,
max_bitrate_kbps: u32 = 6000,

pub fn validate(self: Preferences) !void {
if (!validFramesPerSecond(self.frames_per_second) or !validBitrate(self.max_bitrate_kbps))
return error.InvalidStreamPreferences;
}
};

pub fn validFramesPerSecond(value: u16) bool {
return std.mem.indexOfScalar(u16, &frames_per_second_choices, value) != null;
}

pub fn validBitrate(value: u32) bool {
return std.mem.indexOfScalar(u32, &bitrate_choices_kbps, value) != null;
}

test "stream preferences accept every selectable combination" {
for (frames_per_second_choices) |fps| {
for (bitrate_choices_kbps) |bitrate| {
try (Preferences{ .frames_per_second = fps, .max_bitrate_kbps = bitrate }).validate();
}
}
}

test "stream preferences reject unsupported and overflowing values" {
try (Preferences{}).validate();
for ([_]u16{ 0, 1, 59, 120, std.math.maxInt(u16) }) |fps|
try std.testing.expectError(error.InvalidStreamPreferences, (Preferences{ .frames_per_second = fps }).validate());
for ([_]u32{ 0, 3999, 7000, 20001, std.math.maxInt(u32) }) |bitrate|
try std.testing.expectError(error.InvalidStreamPreferences, (Preferences{ .max_bitrate_kbps = bitrate }).validate());
}
47 changes: 44 additions & 3 deletions src/provider/geforce_now/webrtc_session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const pointer_input = @import("pointer_input.zig");
const sdp_protocol = @import("sdp_protocol.zig");
const signaling_client = @import("signaling_client.zig");
const signaling_protocol = @import("signaling_protocol.zig");
const stream_preferences = @import("stream_preferences.zig");

const c = @cImport({
@cInclude("SDL2/SDL.h");
Expand All @@ -18,7 +19,6 @@ const c = @cImport({
const event_capacity = 64;
const maximum_tracks = 8;
const maximum_channels = 8;
const maximum_bitrate_kbps = 6000;

const EarlyIceCandidates = struct {
messages: std.BoundedArray(signaling_protocol.DecodedMessage, 32) = .{},
Expand Down Expand Up @@ -142,6 +142,7 @@ pub const Session = struct {
started_at: u32 = 0,
last_input_heartbeat: u32 = 0,
bitrate_requested: bool = false,
max_bitrate_kbps: u32,
video_packets: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
audio_packets: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
gamepad_logged: bool = false,
Expand All @@ -156,8 +157,11 @@ pub const Session = struct {
width: u16,
height: u16,
frames_per_second: u16,
max_bitrate_kbps: u32,
) !*Session {
if (cloud.signaling_url == null or cloud.id.len == 0) return error.MissingSignalingData;
if (!stream_preferences.validBitrate(max_bitrate_kbps))
return error.InvalidStreamPreferences;
const session = try allocator.create(Session);
session.* = .{
.allocator = allocator,
Expand All @@ -169,6 +173,7 @@ pub const Session = struct {
.width = width,
.height = height,
.frames_per_second = frames_per_second,
.max_bitrate_kbps = max_bitrate_kbps,
};
return session;
}
Expand Down Expand Up @@ -352,7 +357,7 @@ pub const Session = struct {
pub fn requestBitrate(self: *Session) void {
if (self.bitrate_requested or self.video_track < 0 or
c.go_video_pipeline_has_media(self.video) == 0) return;
if (c.rtcRequestBitrate(self.video_track, maximum_bitrate_kbps * 1000) >= 0)
if (c.rtcRequestBitrate(self.video_track, self.max_bitrate_kbps * 1000) >= 0)
self.bitrate_requested = true;
}

Expand Down Expand Up @@ -432,7 +437,7 @@ pub const Session = struct {
self.width,
self.height,
self.frames_per_second,
maximum_bitrate_kbps,
self.max_bitrate_kbps,
);
defer self.allocator.free(nvst);
try self.signaling.?.sendAnswer(narrowed_sdp, nvst);
Expand Down Expand Up @@ -894,3 +899,39 @@ test "ICE URL construction releases partial allocations" {
}
}.run, .{});
}

test "session bitrate reaches NVST and feedback in the correct units" {
const feedback = @import("gfn_video_feedback_fake");
var cloud = try cloudmatch.parseSession(std.testing.allocator,
\\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":2,"sessionControlInfo":{"ip":"np.example.nvidiagrid.net","port":443}}}
);
defer cloud.deinit();
const pointer: *anyopaque = @ptrFromInt(1);
const session = try Session.create(std.testing.allocator, pointer, pointer, pointer, pointer, &cloud, 1024, 768, 60, 16000);
defer std.testing.allocator.destroy(session);
const answer =
"v=0\r\na=ice-ufrag:test\r\na=ice-pwd:password\r\na=fingerprint:sha-256 AA:BB\r\n";
const nvst = try sdp_protocol.buildNvstAnswer(std.testing.allocator, answer, "", session.width, session.height, session.frames_per_second, session.max_bitrate_kbps);
defer std.testing.allocator.free(nvst);
try std.testing.expect(std.mem.indexOf(u8, nvst, "a=video.maxFPS:60\r\n") != null);
try std.testing.expect(std.mem.indexOf(u8, nvst, "a=vqos.bw.maximumBitrateKbps:16000\r\n") != null);

feedback.calls = 0;
feedback.bitrate_bps = 0;
feedback.result = 0;
feedback.media_available = false;
session.video_track = 1;
session.requestBitrate();
try std.testing.expectEqual(@as(usize, 0), feedback.calls);
feedback.media_available = true;
feedback.result = -1;
session.requestBitrate();
try std.testing.expect(!session.bitrate_requested);
feedback.result = 0;
session.requestBitrate();
try std.testing.expect(session.bitrate_requested);
try std.testing.expectEqual(@as(c_uint, 16_000_000), feedback.bitrate_bps);
session.requestBitrate();
try std.testing.expectEqual(@as(usize, 2), feedback.calls);
try std.testing.expectError(error.InvalidStreamPreferences, Session.create(std.testing.allocator, pointer, pointer, pointer, pointer, &cloud, 1024, 768, 60, std.math.maxInt(u32)));
}
2 changes: 2 additions & 0 deletions src/ui/handheld_ui.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ int go_handheld_ui_pick_title(GoHandheldUi* ui, const GoCatalogTitle* titles, in
int go_handheld_ui_cancelled(const GoHandheldUi* ui);
unsigned int go_handheld_ui_stream_width(const GoHandheldUi* ui);
unsigned int go_handheld_ui_stream_height(const GoHandheldUi* ui);
unsigned int go_handheld_ui_gfn_frames_per_second(const GoHandheldUi* ui);
unsigned int go_handheld_ui_gfn_max_bitrate_kbps(const GoHandheldUi* ui);
void go_handheld_ui_draw_stream_controls(GoHandheldUi* ui, int mouse_mode, float pointer_x,
float pointer_y, int source_width, int source_height,
int show_hint);
Expand Down
Loading
Loading