diff --git a/README.md b/README.md index 7d6dcf2..3aa4850 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.zig b/build.zig index b9fcbae..a1ff4b8 100644 --- a/build.zig +++ b/build.zig @@ -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" }, .{ @@ -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" }, @@ -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| diff --git a/packaging/portmaster/greenovercast/README.md b/packaging/portmaster/greenovercast/README.md index d7cdac4..5bfb4f8 100644 --- a/packaging/portmaster/greenovercast/README.md +++ b/packaging/portmaster/greenovercast/README.md @@ -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 diff --git a/src/app/release.zig b/src/app/release.zig index 369187c..e81d7be 100644 --- a/src/app/release.zig +++ b/src/app/release.zig @@ -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)}); @@ -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; diff --git a/src/provider/geforce_now/session_client.zig b/src/provider/geforce_now/session_client.zig index cb98eed..d47b00e 100644 --- a/src/provider/geforce_now/session_client.zig +++ b/src/provider/geforce_now/session_client.zig @@ -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, @@ -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; @@ -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) = .{}, @@ -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); @@ -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)}); @@ -427,7 +432,7 @@ 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| { @@ -435,13 +440,14 @@ pub const Client = struct { 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, }, ); } @@ -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); +} diff --git a/src/provider/geforce_now/stream_preferences.zig b/src/provider/geforce_now/stream_preferences.zig new file mode 100644 index 0000000..60cdd56 --- /dev/null +++ b/src/provider/geforce_now/stream_preferences.zig @@ -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()); +} diff --git a/src/provider/geforce_now/webrtc_session.zig b/src/provider/geforce_now/webrtc_session.zig index 788b6c5..12ae8cf 100644 --- a/src/provider/geforce_now/webrtc_session.zig +++ b/src/provider/geforce_now/webrtc_session.zig @@ -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"); @@ -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) = .{}, @@ -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, @@ -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, @@ -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; } @@ -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; } @@ -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); @@ -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))); +} diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h index 160f9ab..b55950e 100644 --- a/src/ui/handheld_ui.h +++ b/src/ui/handheld_ui.h @@ -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); diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 39fb6c0..700deb2 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -5,6 +5,7 @@ const keyboard = @import("keyboard.zig"); const library = @import("library_view.zig"); const navigation = @import("navigation_repeat.zig"); const persistent = @import("persistent_settings.zig"); +const gfn_defaults = @import("gfn_stream_preferences").Preferences{}; const provider_picker = @import("provider_picker.zig"); const provider_badge = @import("provider_badge.zig"); const font = @import("pixel_font.zig"); @@ -785,3 +786,11 @@ pub export fn go_handheld_ui_stream_width(ui: ?*const Ui) c_uint { pub export fn go_handheld_ui_stream_height(ui: ?*const Ui) c_uint { return (ui orelse return 480).stream_height; } + +pub export fn go_handheld_ui_gfn_frames_per_second(ui: ?*const Ui) c_uint { + return (ui orelse return gfn_defaults.frames_per_second).settings.geforce_now.frames_per_second; +} + +pub export fn go_handheld_ui_gfn_max_bitrate_kbps(ui: ?*const Ui) c_uint { + return (ui orelse return gfn_defaults.max_bitrate_kbps).settings.geforce_now.max_bitrate_kbps; +} diff --git a/src/ui/persistent_settings.zig b/src/ui/persistent_settings.zig index 32eef01..7c4bdf1 100644 --- a/src/ui/persistent_settings.zig +++ b/src/ui/persistent_settings.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const gfn_stream_preferences = @import("gfn_stream_preferences"); pub const max_games = 1024; pub const product_id_capacity = 64; @@ -18,6 +19,7 @@ pub const Store = struct { path_length: usize = 0, face_buttons: FaceButtonMode = .system, artwork_enabled: bool = true, + geforce_now: gfn_stream_preferences.Preferences = .{}, games: [max_games]GameSettings = [_]GameSettings{.{}} ** max_games, game_count: usize = 0, @@ -66,20 +68,25 @@ pub const Store = struct { var file = try cwd.createFile(temporary_path, .{ .truncate = true, .mode = 0o600 }); var closed = false; defer if (!closed) file.close(); - var writer = file.writer(); + try self.write(file.writer()); + try file.sync(); + file.close(); + closed = true; + try cwd.rename(temporary_path, path); + } + + fn write(self: *const Store, writer: anytype) !void { try writer.writeAll("version\t1\n"); try writer.print("face_buttons\t{s}\n", .{@tagName(self.face_buttons)}); try writer.print("artwork\t{d}\n", .{@intFromBool(self.artwork_enabled)}); + try writer.print("geforce_now.frames_per_second\t{d}\n", .{self.geforce_now.frames_per_second}); + try writer.print("geforce_now.max_bitrate_kbps\t{d}\n", .{self.geforce_now.max_bitrate_kbps}); for (self.games[0..self.game_count]) |*entry| { try writer.print("game\t{s}\t{d}\n", .{ productId(entry), @intFromBool(entry.favorite), }); } - try file.sync(); - file.close(); - closed = true; - try cwd.rename(temporary_path, path); } fn load(self: *Store) !void { @@ -118,6 +125,16 @@ pub const Store = struct { } else if (std.mem.eql(u8, kind, "artwork")) { const value = fields.next() orelse continue; self.artwork_enabled = std.mem.eql(u8, value, "1"); + } else if (std.mem.eql(u8, kind, "geforce_now.frames_per_second")) { + const value = fields.next() orelse continue; + const parsed = std.fmt.parseUnsigned(u16, value, 10) catch continue; + if (gfn_stream_preferences.validFramesPerSecond(parsed)) + self.geforce_now.frames_per_second = parsed; + } else if (std.mem.eql(u8, kind, "geforce_now.max_bitrate_kbps")) { + const value = fields.next() orelse continue; + const parsed = std.fmt.parseUnsigned(u32, value, 10) catch continue; + if (gfn_stream_preferences.validBitrate(parsed)) + self.geforce_now.max_bitrate_kbps = parsed; } else if (std.mem.eql(u8, kind, "game")) { const id = fields.next() orelse continue; const favorite = fields.next() orelse continue; @@ -145,18 +162,19 @@ test "settings round trip through the file format" { var store = Store{}; store.face_buttons = .swapped; store.artwork_enabled = false; + store.geforce_now = .{ .frames_per_second = 60, .max_bitrate_kbps = 12000 }; const game_settings = store.game("PRODUCT-1").?; game_settings.favorite = true; var data = std.ArrayList(u8).init(std.testing.allocator); defer data.deinit(); - try data.writer().writeAll("version\t1\nface_buttons\tswapped\nartwork\t0\n"); - try data.writer().writeAll("game\tPRODUCT-1\t1\n"); + try store.write(data.writer()); var parsed = Store{}; try parsed.parse(data.items); try std.testing.expectEqual(FaceButtonMode.swapped, parsed.face_buttons); try std.testing.expect(!parsed.artwork_enabled); + try std.testing.expectEqualDeep(store.geforce_now, parsed.geforce_now); const parsed_game = parsed.findGame("PRODUCT-1").?; try std.testing.expect(parsed_game.favorite); } @@ -169,3 +187,42 @@ test "settings reject unknown versions and unsafe ids" { try std.testing.expectError(error.UnsupportedSettings, store.parse("artwork\t0\nversion\t1\n")); try std.testing.expect(store.game("bad\tid") == null); } + +test "settings keep GFN stream quality within the supported choices" { + var store = Store{}; + try store.parse("version\t1\ngeforce_now.frames_per_second\t60\ngeforce_now.max_bitrate_kbps\t12000\n"); + try std.testing.expectEqual(@as(u16, 60), store.geforce_now.frames_per_second); + try std.testing.expectEqual(@as(u32, 12000), store.geforce_now.max_bitrate_kbps); + + var invalid = Store{}; + try invalid.parse("version\t1\ngeforce_now.frames_per_second\t1\ngeforce_now.max_bitrate_kbps\t0\n"); + try std.testing.expectEqualDeep(gfn_stream_preferences.Preferences{}, invalid.geforce_now); + + try invalid.parse("version\t1\ngeforce_now.max_bitrate_kbps\t4294967295\n"); + try std.testing.expectEqualDeep(gfn_stream_preferences.Preferences{}, invalid.geforce_now); +} + +test "only GFN-qualified keys change GFN quality" { + var store = Store{}; + try store.parse("version\t1\nxbox.frames_per_second\t60\nxbox.max_bitrate_kbps\t16000\nframes_per_second\t60\nmax_bitrate_kbps\t16000\n"); + try std.testing.expectEqualDeep(gfn_stream_preferences.Preferences{}, store.geforce_now); +} + +test "provider preferences survive saving and reopening the settings file" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + const root = try directory.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(root); + const path = try std.fs.path.join(std.testing.allocator, &.{ root, "settings.tsv" }); + defer std.testing.allocator.free(path); + var store = try Store.init(path); + store.geforce_now = .{ .frames_per_second = 60, .max_bitrate_kbps = 16000 }; + store.face_buttons = .swapped; + store.artwork_enabled = false; + try store.save(); + + const reloaded = try Store.init(path); + try std.testing.expectEqualDeep(store.geforce_now, reloaded.geforce_now); + try std.testing.expectEqual(store.face_buttons, reloaded.face_buttons); + try std.testing.expectEqual(store.artwork_enabled, reloaded.artwork_enabled); +} diff --git a/src/ui/settings_view.zig b/src/ui/settings_view.zig index 74eb580..cb3ac47 100644 --- a/src/ui/settings_view.zig +++ b/src/ui/settings_view.zig @@ -4,6 +4,7 @@ const navigation = @import("navigation_repeat.zig"); const font = @import("pixel_font.zig"); const provider_badge = @import("provider_badge.zig"); const settings = @import("persistent_settings.zig"); +const gfn_stream_preferences = @import("gfn_stream_preferences"); const style = @import("view_style.zig"); const c = @cImport({ @@ -23,10 +24,29 @@ const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int; const Row = enum { face_buttons, artwork, + framerate, + bitrate, service, sign_out, }; +const bitrate_choices = gfn_stream_preferences.bitrate_choices_kbps; + +const Adjustment = enum { decrease, increase }; + +fn rowsFor(provider: provider_badge.Provider) []const Row { + return switch (provider) { + .xbox => &.{ .face_buttons, .artwork, .service, .sign_out }, + .geforce_now => &.{ .face_buttons, .artwork, .framerate, .bitrate, .service, .sign_out }, + }; +} + +fn adjustBitrate(current: u32, adjustment: Adjustment) u32 { + const index = std.mem.indexOfScalar(u32, &bitrate_choices, current) orelse return bitrate_choices[0]; + const offset: usize = if (adjustment == .decrease) bitrate_choices.len - 1 else 1; + return bitrate_choices[(index + offset) % bitrate_choices.len]; +} + const Action = enum { none, switch_provider, @@ -54,10 +74,10 @@ pub fn run( if (event.type == c.SDL_QUIT) return .cancelled; if (event.type == c.SDL_KEYDOWN) switch (event.key.keysym.sym) { c.SDLK_ESCAPE => return .back, - c.SDLK_UP => selected = previousRow(selected), - c.SDLK_DOWN => selected = nextRow(selected), + c.SDLK_UP => selected = previousRow(provider, selected), + c.SDLK_DOWN => selected = nextRow(provider, selected), c.SDLK_RETURN => { - const action = activate(selected, controller, store); + const action = activate(selected, controller, store, provider, .increase); if (resolveAction(action, renderer, controller, store.face_buttons, stop_requested, stop_context)) |result| return result; dirty = true; @@ -72,22 +92,24 @@ pub fn run( switch (button) { c.SDL_CONTROLLER_BUTTON_B => return .back, c.SDL_CONTROLLER_BUTTON_A => { - const action = activate(selected, controller, store); + const action = activate(selected, controller, store, provider, .increase); if (resolveAction(action, renderer, controller, store.face_buttons, stop_requested, stop_context)) |result| return result; dirty = true; }, c.SDL_CONTROLLER_BUTTON_DPAD_UP => { - selected = previousRow(selected); + selected = previousRow(provider, selected); repeat.begin(.up, c.SDL_GetTicks()); }, c.SDL_CONTROLLER_BUTTON_DPAD_DOWN => { - selected = nextRow(selected); + selected = nextRow(provider, selected); repeat.begin(.down, c.SDL_GetTicks()); }, c.SDL_CONTROLLER_BUTTON_DPAD_LEFT, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT => { - if (selected == .face_buttons or selected == .artwork) - _ = activate(selected, controller, store); + if (selected != .service and selected != .sign_out) { + const adjustment: Adjustment = if (button == c.SDL_CONTROLLER_BUTTON_DPAD_LEFT) .decrease else .increase; + _ = activate(selected, controller, store, provider, adjustment); + } }, else => {}, } @@ -100,11 +122,11 @@ pub fn run( const direction = heldDirection(controller, &axis_latch); if (repeat.update(direction, c.SDL_GetTicks())) { if (direction == .up) { - selected = previousRow(selected); + selected = previousRow(provider, selected); dirty = true; } if (direction == .down) { - selected = nextRow(selected); + selected = nextRow(provider, selected); dirty = true; } } @@ -120,6 +142,8 @@ fn activate( row: Row, controller: *c.GoControllerInput, store: *settings.Store, + provider: provider_badge.Provider, + adjustment: Adjustment, ) Action { switch (row) { .face_buttons => { @@ -130,6 +154,7 @@ fn activate( ); }, .artwork => store.artwork_enabled = !store.artwork_enabled, + .framerate, .bitrate => if (!changeStreamQuality(row, provider, store, adjustment)) return .none, .service => return .switch_provider, .sign_out => return .sign_out, } @@ -137,6 +162,16 @@ fn activate( return .none; } +fn changeStreamQuality(row: Row, provider: provider_badge.Provider, store: *settings.Store, adjustment: Adjustment) bool { + if (provider != .geforce_now) return false; + switch (row) { + .framerate => store.geforce_now.frames_per_second = if (store.geforce_now.frames_per_second == 60) 30 else 60, + .bitrate => store.geforce_now.max_bitrate_kbps = adjustBitrate(store.geforce_now.max_bitrate_kbps, adjustment), + else => return false, + } + return true; +} + fn resolveAction( action: Action, renderer: *c.SDL_Renderer, @@ -161,22 +196,16 @@ fn resolveAction( }; } -fn previousRow(row: Row) Row { - return switch (row) { - .face_buttons => .sign_out, - .artwork => .face_buttons, - .service => .artwork, - .sign_out => .service, - }; +fn previousRow(provider: provider_badge.Provider, row: Row) Row { + const rows = rowsFor(provider); + const index = std.mem.indexOfScalar(Row, rows, row) orelse return rows[0]; + return rows[(index + rows.len - 1) % rows.len]; } -fn nextRow(row: Row) Row { - return switch (row) { - .face_buttons => .artwork, - .artwork => .service, - .service => .sign_out, - .sign_out => .face_buttons, - }; +fn nextRow(provider: provider_badge.Provider, row: Row) Row { + const rows = rowsFor(provider); + const index = std.mem.indexOfScalar(Row, rows, row) orelse return rows[0]; + return rows[(index + 1) % rows.len]; } fn heldDirection(controller: *c.GoControllerInput, latch: *navigation.AxisLatch) navigation.Direction { @@ -203,14 +232,28 @@ fn draw( _ = c.SDL_RenderFillRect(renderer, &header); _ = c.SDL_RenderFillRect(renderer, &footer); font.text(renderer, 18, 14, 4, "SETTINGS", style.bright()); + provider_badge.draw(renderer, provider); - drawRow(renderer, 82, "FACE BUTTONS", if (store.face_buttons == .system) "SYSTEM" else "SWAPPED", selected == .face_buttons); - drawRow(renderer, 130, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork); - drawRow(renderer, 178, "STREAMING SERVICE", if (provider == .xbox) "XBOX" else "GEFORCE NOW", selected == .service); - drawRow(renderer, 226, "ACCOUNT", "SIGN OUT", selected == .sign_out); + var fps_buffer: [16]u8 = undefined; + const fps_text = std.fmt.bufPrintZ(&fps_buffer, "{d} FPS", .{store.geforce_now.frames_per_second}) catch unreachable; + var bitrate_buffer: [16]u8 = undefined; + const bitrate_text = std.fmt.bufPrintZ(&bitrate_buffer, "{d} MBPS", .{store.geforce_now.max_bitrate_kbps / 1000}) catch unreachable; + + for (rowsFor(provider), 0..) |row, index| { + const label: [*:0]const u8, const value: [*:0]const u8 = switch (row) { + .face_buttons => .{ "FACE BUTTONS", if (store.face_buttons == .system) "SYSTEM" else "SWAPPED" }, + .artwork => .{ "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF" }, + .framerate => .{ "TARGET FRAMERATE", fps_text }, + .bitrate => .{ "MAX BITRATE", bitrate_text }, + .service => .{ "STREAMING SERVICE", if (provider == .xbox) "XBOX" else "GEFORCE NOW" }, + .sign_out => .{ "ACCOUNT", "SIGN OUT" }, + }; + drawRow(renderer, 82 + @as(c_int, @intCast(index)) * 46, label, value, selected == row); + } drawMappingExplanation(renderer, store.face_buttons); - font.text(renderer, 18, 340, 2, "USE SWAPPED ONLY IF BUTTONS ARE REVERSED", style.muted()); + if (provider == .geforce_now) + font.text(renderer, 18, 366, 2, "QUALITY APPLIES TO THE NEXT GFN STREAM", style.muted()); const prompts = [_]controls.Prompt{ controls.Prompt.one(controls.face(store.face_buttons, .a), "CHANGE"), controls.Prompt.one(.dpad, "MOVE"), @@ -252,6 +295,82 @@ fn drawMappingExplanation(renderer: *c.SDL_Renderer, mode: settings.FaceButtonMo controls.drawSwapPair(renderer, 390, 176, .a, .b, style.accent()); controls.drawSwapPair(renderer, 390, 208, .x, .y, style.accent()); } + font.text(renderer, 390, 270, 2, "USE SWAPPED", style.muted()); + font.text(renderer, 390, 292, 2, "ONLY IF BUTTONS", style.muted()); + font.text(renderer, 390, 314, 2, "ARE REVERSED", style.muted()); +} + +test "provider navigation visits only visible settings rows" { + for ([_]provider_badge.Provider{ .xbox, .geforce_now }) |provider| { + const rows = rowsFor(provider); + for (rows, 0..) |row, index| { + try std.testing.expectEqual(rows[(index + 1) % rows.len], nextRow(provider, row)); + try std.testing.expectEqual(rows[(index + rows.len - 1) % rows.len], previousRow(provider, row)); + } + } + try std.testing.expectEqualSlices(Row, &.{ .face_buttons, .artwork, .service, .sign_out }, rowsFor(.xbox)); + try std.testing.expectEqualSlices(Row, &.{ .face_buttons, .artwork, .framerate, .bitrate, .service, .sign_out }, rowsFor(.geforce_now)); +} + +test "Xbox settings cannot change GFN quality" { + var store = settings.Store{}; + const before = store.geforce_now; + for ([_]Adjustment{ .decrease, .increase }) |adjustment| { + try std.testing.expect(!changeStreamQuality(.framerate, .xbox, &store, adjustment)); + try std.testing.expect(!changeStreamQuality(.bitrate, .xbox, &store, adjustment)); + } + try std.testing.expectEqualDeep(before, store.geforce_now); + try std.testing.expect(changeStreamQuality(.framerate, .geforce_now, &store, .increase)); + try std.testing.expectEqual(@as(u16, 60), store.geforce_now.frames_per_second); + try std.testing.expect(changeStreamQuality(.bitrate, .geforce_now, &store, .increase)); + try std.testing.expectEqual(@as(u32, 8000), store.geforce_now.max_bitrate_kbps); + try std.testing.expect(!changeStreamQuality(.bitrate, .xbox, &store, .decrease)); + try std.testing.expectEqual(@as(u32, 8000), store.geforce_now.max_bitrate_kbps); + for (0..bitrate_choices.len) |_| _ = changeStreamQuality(.bitrate, .geforce_now, &store, .increase); + try std.testing.expectEqual(@as(u32, 8000), store.geforce_now.max_bitrate_kbps); + _ = changeStreamQuality(.framerate, .geforce_now, &store, .decrease); + try std.testing.expectEqual(@as(u16, 30), store.geforce_now.frames_per_second); + try std.testing.expectEqual(settings.FaceButtonMode.system, store.face_buttons); + try std.testing.expect(store.artwork_enabled); +} + +test "bitrate adjustments follow direction and wrap at both ends" { + for (bitrate_choices, 0..) |value, index| { + const lower = bitrate_choices[(index + bitrate_choices.len - 1) % bitrate_choices.len]; + const higher = bitrate_choices[(index + 1) % bitrate_choices.len]; + try std.testing.expectEqual(lower, adjustBitrate(value, .decrease)); + try std.testing.expectEqual(higher, adjustBitrate(value, .increase)); + try std.testing.expectEqual(value, adjustBitrate(lower, .increase)); + try std.testing.expectEqual(value, adjustBitrate(higher, .decrease)); + } + var store = settings.Store{}; + store.geforce_now.max_bitrate_kbps = 4000; + try std.testing.expect(changeStreamQuality(.bitrate, .geforce_now, &store, .decrease)); + try std.testing.expectEqual(@as(u32, 20000), store.geforce_now.max_bitrate_kbps); + try std.testing.expect(changeStreamQuality(.bitrate, .geforce_now, &store, .increase)); + try std.testing.expectEqual(@as(u32, 4000), store.geforce_now.max_bitrate_kbps); + try std.testing.expectEqual(@as(u16, 30), store.geforce_now.frames_per_second); + try std.testing.expectEqual(settings.FaceButtonMode.system, store.face_buttons); + try std.testing.expect(store.artwork_enabled); +} + +test "settings render only the active provider's rows" { + const surface = c.SDL_CreateRGBSurfaceWithFormat(0, 640, 480, 32, c.SDL_PIXELFORMAT_RGBA32) orelse return error.SurfaceCreationFailed; + defer c.SDL_FreeSurface(surface); + const renderer = c.SDL_CreateSoftwareRenderer(surface) orelse return error.RendererCreationFailed; + defer c.SDL_DestroyRenderer(renderer); + const store = settings.Store{}; + const accent = style.accent(); + const selected_pixel = c.SDL_MapRGBA(surface.*.format, accent.r, accent.g, accent.b, accent.a); + const pixels: [*]const u32 = @ptrCast(@alignCast(surface.*.pixels)); + const stride: usize = @intCast(@divExact(surface.*.pitch, 4)); + + draw(renderer, &store, .xbox, .service); + try std.testing.expectEqual(selected_pixel, pixels[175 * stride + 20]); + try std.testing.expect(pixels[267 * stride + 20] != selected_pixel); + draw(renderer, &store, .geforce_now, .service); + try std.testing.expect(pixels[175 * stride + 20] != selected_pixel); + try std.testing.expectEqual(selected_pixel, pixels[267 * stride + 20]); } fn confirmSignOut( diff --git a/tests/gfn_http_fake.zig b/tests/gfn_http_fake.zig index f3d4cd8..a279b7e 100644 --- a/tests/gfn_http_fake.zig +++ b/tests/gfn_http_fake.zig @@ -11,6 +11,9 @@ pub var polls: usize = 0; pub var cancelled = false; pub var cancel_on_poll: usize = 1; pub var response: ?*Response = null; +pub var on_request: ?*const fn ([]const u8, []const u8) void = null; +pub var last_body: [8192]u8 = undefined; +pub var last_body_length: usize = 0; pub fn reset() void { requests = 0; @@ -18,6 +21,8 @@ pub fn reset() void { cancelled = false; cancel_on_poll = 1; response = null; + on_request = null; + last_body_length = 0; } var no_content = Response{ .data = null, .len = 0, .status = 204 }; @@ -54,9 +59,9 @@ pub fn authClient(comptime Client: type) Client { } export fn go_http_request_bounded_cancelable( - _: [*:0]const u8, - _: [*:0]const u8, - _: ?[*:0]const u8, + method: [*:0]const u8, + url: [*:0]const u8, + body: ?[*:0]const u8, _: ?[*]const [*:0]const u8, _: c_int, _: usize, @@ -67,6 +72,13 @@ export fn go_http_request_bounded_cancelable( if (cancel) |check| { if (check(context) != 0) return null; } + const body_slice = if (body) |value| std.mem.span(value) else null; + if (body_slice) |data| { + if (data.len > last_body.len) return null; + @memcpy(last_body[0..data.len], data); + last_body_length = data.len; + } + if (on_request) |handler| handler(std.mem.span(method), std.mem.span(url)); return response; } diff --git a/tests/gfn_video_feedback_fake.zig b/tests/gfn_video_feedback_fake.zig new file mode 100644 index 0000000..8ec754d --- /dev/null +++ b/tests/gfn_video_feedback_fake.zig @@ -0,0 +1,14 @@ +pub var media_available = true; +pub var result: c_int = 0; +pub var calls: usize = 0; +pub var bitrate_bps: c_uint = 0; + +export fn go_video_pipeline_has_media(_: ?*const anyopaque) c_int { + return @intFromBool(media_available); +} + +export fn rtcRequestBitrate(_: c_int, bitrate: c_uint) c_int { + calls += 1; + bitrate_bps = bitrate; + return result; +}