From 52900e53f5fc3c0b9317f6dcc85033b606520456 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:43:49 -0500 Subject: [PATCH 1/4] feat(ui): make stream framerate and bitrate configurable Adds TARGET FRAMERATE and MAX BITRATE rows to the settings screen so the GeForce NOW stream profile can be changed on device instead of being fixed at build time. Defaults are unchanged (30 fps, 6000 kbps), so behaviour on lower-end chips and wifi cards is preserved unless the user opts in. - persistent_settings: store frames_per_second and max_bitrate_kbps with save/parse entries; unknown keys are still skipped, so existing settings files load unchanged and stay readable by older builds - handheld_ui: expose both values to C through accessors that mirror the existing stream_width/stream_height pattern - geforce_now: read the framerate when the session client is created, and the bitrate ceiling at both request sites, replacing the fixed constant - settings_view: two new rows, framerate toggling 30/60 and bitrate cycling 4/6/8/12/16/20 Mbps Both values are negotiated when a stream starts, so a change applies to the next stream rather than the running one. The settings screen says so. Co-Authored-By: Claude Opus 5 --- src/provider/geforce_now/session_client.zig | 2 ++ src/provider/geforce_now/webrtc_session.zig | 6 ++-- src/ui/handheld_ui.h | 2 ++ src/ui/handheld_ui.zig | 8 +++++ src/ui/persistent_settings.zig | 10 ++++++ src/ui/settings_view.zig | 38 +++++++++++++++++---- 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/provider/geforce_now/session_client.zig b/src/provider/geforce_now/session_client.zig index 6fad6bf..46823e2 100644 --- a/src/provider/geforce_now/session_client.zig +++ b/src/provider/geforce_now/session_client.zig @@ -48,6 +48,8 @@ pub const Client = struct { .client_id = undefined, }; uuid.generate(&client.client_id); + const configured_fps = c.go_handheld_ui_frames_per_second(client.ui); + if (configured_fps > 0) client.stream_frames_per_second = @intCast(configured_fps); return client; } diff --git a/src/provider/geforce_now/webrtc_session.zig b/src/provider/geforce_now/webrtc_session.zig index 788b6c5..b8395d8 100644 --- a/src/provider/geforce_now/webrtc_session.zig +++ b/src/provider/geforce_now/webrtc_session.zig @@ -18,7 +18,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) = .{}, @@ -352,7 +351,8 @@ 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) + const ceiling_kbps = c.go_handheld_ui_max_bitrate_kbps(self.ui); + if (c.rtcRequestBitrate(self.video_track, @intCast(ceiling_kbps * 1000)) >= 0) self.bitrate_requested = true; } @@ -432,7 +432,7 @@ pub const Session = struct { self.width, self.height, self.frames_per_second, - maximum_bitrate_kbps, + @intCast(c.go_handheld_ui_max_bitrate_kbps(self.ui)), ); defer self.allocator.free(nvst); try self.signaling.?.sendAnswer(narrowed_sdp, nvst); diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h index d1f49c5..b1025d7 100644 --- a/src/ui/handheld_ui.h +++ b/src/ui/handheld_ui.h @@ -56,6 +56,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_frames_per_second(const GoHandheldUi* ui); +unsigned int go_handheld_ui_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 9e38a0e..9f13109 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -747,3 +747,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_frames_per_second(ui: ?*const Ui) c_uint { + return (ui orelse return 30).settings.frames_per_second; +} + +pub export fn go_handheld_ui_max_bitrate_kbps(ui: ?*const Ui) c_uint { + return (ui orelse return 6000).settings.max_bitrate_kbps; +} diff --git a/src/ui/persistent_settings.zig b/src/ui/persistent_settings.zig index 32eef01..cc1bb65 100644 --- a/src/ui/persistent_settings.zig +++ b/src/ui/persistent_settings.zig @@ -18,6 +18,8 @@ pub const Store = struct { path_length: usize = 0, face_buttons: FaceButtonMode = .system, artwork_enabled: bool = true, + frames_per_second: u16 = 30, + max_bitrate_kbps: u32 = 6000, games: [max_games]GameSettings = [_]GameSettings{.{}} ** max_games, game_count: usize = 0, @@ -70,6 +72,8 @@ pub const Store = struct { 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("frames_per_second\t{d}\n", .{self.frames_per_second}); + try writer.print("max_bitrate_kbps\t{d}\n", .{self.max_bitrate_kbps}); for (self.games[0..self.game_count]) |*entry| { try writer.print("game\t{s}\t{d}\n", .{ productId(entry), @@ -118,6 +122,12 @@ 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, "frames_per_second")) { + const value = fields.next() orelse continue; + self.frames_per_second = std.fmt.parseUnsigned(u16, value, 10) catch continue; + } else if (std.mem.eql(u8, kind, "max_bitrate_kbps")) { + const value = fields.next() orelse continue; + self.max_bitrate_kbps = std.fmt.parseUnsigned(u32, value, 10) catch continue; } else if (std.mem.eql(u8, kind, "game")) { const id = fields.next() orelse continue; const favorite = fields.next() orelse continue; diff --git a/src/ui/settings_view.zig b/src/ui/settings_view.zig index 74eb580..418c46e 100644 --- a/src/ui/settings_view.zig +++ b/src/ui/settings_view.zig @@ -23,10 +23,21 @@ const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int; const Row = enum { face_buttons, artwork, + framerate, + bitrate, service, sign_out, }; +const bitrate_choices = [_]u32{ 4000, 6000, 8000, 12000, 16000, 20000 }; + +fn nextBitrate(current: u32) u32 { + for (bitrate_choices, 0..) |value, index| { + if (value == current) return bitrate_choices[(index + 1) % bitrate_choices.len]; + } + return bitrate_choices[0]; +} + const Action = enum { none, switch_provider, @@ -86,7 +97,7 @@ pub fn run( 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) + if (selected != .service and selected != .sign_out) _ = activate(selected, controller, store); }, else => {}, @@ -130,6 +141,8 @@ fn activate( ); }, .artwork => store.artwork_enabled = !store.artwork_enabled, + .framerate => store.frames_per_second = if (store.frames_per_second >= 60) 30 else 60, + .bitrate => store.max_bitrate_kbps = nextBitrate(store.max_bitrate_kbps), .service => return .switch_provider, .sign_out => return .sign_out, } @@ -165,7 +178,9 @@ fn previousRow(row: Row) Row { return switch (row) { .face_buttons => .sign_out, .artwork => .face_buttons, - .service => .artwork, + .framerate => .artwork, + .bitrate => .framerate, + .service => .bitrate, .sign_out => .service, }; } @@ -173,7 +188,9 @@ fn previousRow(row: Row) Row { fn nextRow(row: Row) Row { return switch (row) { .face_buttons => .artwork, - .artwork => .service, + .artwork => .framerate, + .framerate => .bitrate, + .bitrate => .service, .service => .sign_out, .sign_out => .face_buttons, }; @@ -204,13 +221,20 @@ fn draw( _ = c.SDL_RenderFillRect(renderer, &footer); font.text(renderer, 18, 14, 4, "SETTINGS", style.bright()); + var fps_buffer: [16]u8 = undefined; + const fps_text = std.fmt.bufPrintZ(&fps_buffer, "{d} FPS", .{store.frames_per_second}) catch "30 FPS"; + var bitrate_buffer: [16]u8 = undefined; + const bitrate_text = std.fmt.bufPrintZ(&bitrate_buffer, "{d} MBPS", .{store.max_bitrate_kbps / 1000}) catch "6 MBPS"; + 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); + drawRow(renderer, 128, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork); + drawRow(renderer, 174, "TARGET FRAMERATE", fps_text, selected == .framerate); + drawRow(renderer, 220, "MAX BITRATE", bitrate_text, selected == .bitrate); + drawRow(renderer, 266, "STREAMING SERVICE", if (provider == .xbox) "XBOX" else "GEFORCE NOW", selected == .service); + drawRow(renderer, 312, "ACCOUNT", "SIGN OUT", selected == .sign_out); drawMappingExplanation(renderer, store.face_buttons); - font.text(renderer, 18, 340, 2, "USE SWAPPED ONLY IF BUTTONS ARE REVERSED", style.muted()); + font.text(renderer, 18, 366, 2, "FRAMERATE AND BITRATE APPLY ON NEXT STREAM", style.muted()); const prompts = [_]controls.Prompt{ controls.Prompt.one(controls.face(store.face_buttons, .a), "CHANGE"), controls.Prompt.one(.dpad, "MOVE"), From 9e42628988365baf74f08a400ce83da78119d9e1 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:04:53 -0500 Subject: [PATCH 2/4] fix(gfn): keep configured framerate when selecting stream mode selectStreamMode reset the stream rate to the hardcoded 30 fps and matched entitled resolutions against that constant, so a 60 fps setting never reached the session request. Track the configured rate separately and use it for both the fallback and the resolution match. Co-Authored-By: Claude Opus 5.5 --- src/provider/geforce_now/session_client.zig | 30 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/provider/geforce_now/session_client.zig b/src/provider/geforce_now/session_client.zig index 46823e2..176ff00 100644 --- a/src/provider/geforce_now/session_client.zig +++ b/src/provider/geforce_now/session_client.zig @@ -33,6 +33,7 @@ pub const Client = struct { last_response_retryable: ?bool = null, stream_width: u16 = fallback_stream_width, stream_height: u16 = fallback_stream_height, + requested_frames_per_second: u16 = preferred_frames_per_second, stream_frames_per_second: u16 = preferred_frames_per_second, pub fn create( @@ -49,7 +50,8 @@ pub const Client = struct { }; uuid.generate(&client.client_id); const configured_fps = c.go_handheld_ui_frames_per_second(client.ui); - if (configured_fps > 0) client.stream_frames_per_second = @intCast(configured_fps); + if (configured_fps > 0) client.requested_frames_per_second = @intCast(configured_fps); + client.stream_frames_per_second = client.requested_frames_per_second; return client; } @@ -350,7 +352,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.requested_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)}); @@ -371,7 +373,7 @@ pub const Client = struct { if (summary.bestForDisplayWithin( display_width, display_height, - preferred_frames_per_second, + self.requested_frames_per_second, maximum_stream_width, maximum_stream_height, )) |selected| { @@ -732,3 +734,25 @@ test "membership and region discovery requests preserve cancellation" { try std.testing.expectEqual(@as(usize, 1), http.requests); try std.testing.expectEqual(@as(usize, 1), http.polls); } + +test "stream mode selection keeps the configured frame rate" { + const http = @import("gfn_http_fake"); + http.reset(); + http.cancel_on_poll = std.math.maxInt(usize); + 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, + .requested_frames_per_second = 60, + }; + http.replyJson( + \\{"features":{"resolutions":[ + \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":30,"isEntitled":true}, + \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":60,"isEntitled":true} + \\]}} + ); + try client.selectStreamMode("https://example.invalid/", auth.bearer().?, 640, 480); + try std.testing.expectEqual(@as(u16, 60), client.stream_frames_per_second); +} From dbed9b6cf32e720c88ee4dbc1169a944f8e8b3e4 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:04:53 -0500 Subject: [PATCH 3/4] fix(ui): ignore unsupported stream quality settings Only accept persisted framerate and bitrate values from the choices the settings view offers; anything else keeps the default. The choice lists now live in persistent_settings so the view and the parser share them. Co-Authored-By: Claude Opus 5.5 --- src/ui/persistent_settings.zig | 29 +++++++++++++++++++++++++++-- src/ui/settings_view.zig | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/ui/persistent_settings.zig b/src/ui/persistent_settings.zig index cc1bb65..4a5ac87 100644 --- a/src/ui/persistent_settings.zig +++ b/src/ui/persistent_settings.zig @@ -2,6 +2,8 @@ const std = @import("std"); pub const max_games = 1024; pub const product_id_capacity = 64; +pub const frames_per_second_choices = [_]u16{ 30, 60 }; +pub const bitrate_choices_kbps = [_]u32{ 4000, 6000, 8000, 12000, 16000, 20000 }; pub const FaceButtonMode = enum { system, @@ -124,10 +126,14 @@ pub const Store = struct { self.artwork_enabled = std.mem.eql(u8, value, "1"); } else if (std.mem.eql(u8, kind, "frames_per_second")) { const value = fields.next() orelse continue; - self.frames_per_second = std.fmt.parseUnsigned(u16, value, 10) catch continue; + const parsed = std.fmt.parseUnsigned(u16, value, 10) catch continue; + if (isChoice(u16, &frames_per_second_choices, parsed)) + self.frames_per_second = parsed; } else if (std.mem.eql(u8, kind, "max_bitrate_kbps")) { const value = fields.next() orelse continue; - self.max_bitrate_kbps = std.fmt.parseUnsigned(u32, value, 10) catch continue; + const parsed = std.fmt.parseUnsigned(u32, value, 10) catch continue; + if (isChoice(u32, &bitrate_choices_kbps, parsed)) + self.max_bitrate_kbps = parsed; } else if (std.mem.eql(u8, kind, "game")) { const id = fields.next() orelse continue; const favorite = fields.next() orelse continue; @@ -151,6 +157,10 @@ pub fn validProductId(value: []const u8) bool { return true; } +fn isChoice(comptime T: type, choices: []const T, value: T) bool { + return std.mem.indexOfScalar(T, choices, value) != null; +} + test "settings round trip through the file format" { var store = Store{}; store.face_buttons = .swapped; @@ -179,3 +189,18 @@ 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 stream quality within the supported choices" { + var store = Store{}; + try store.parse("version\t1\nframes_per_second\t60\nmax_bitrate_kbps\t12000\n"); + try std.testing.expectEqual(@as(u16, 60), store.frames_per_second); + try std.testing.expectEqual(@as(u32, 12000), store.max_bitrate_kbps); + + var invalid = Store{}; + try invalid.parse("version\t1\nframes_per_second\t1\nmax_bitrate_kbps\t0\n"); + try std.testing.expectEqual(@as(u16, 30), invalid.frames_per_second); + try std.testing.expectEqual(@as(u32, 6000), invalid.max_bitrate_kbps); + + try invalid.parse("version\t1\nmax_bitrate_kbps\t4294967295\n"); + try std.testing.expectEqual(@as(u32, 6000), invalid.max_bitrate_kbps); +} diff --git a/src/ui/settings_view.zig b/src/ui/settings_view.zig index 418c46e..c772bcc 100644 --- a/src/ui/settings_view.zig +++ b/src/ui/settings_view.zig @@ -29,7 +29,7 @@ const Row = enum { sign_out, }; -const bitrate_choices = [_]u32{ 4000, 6000, 8000, 12000, 16000, 20000 }; +const bitrate_choices = settings.bitrate_choices_kbps; fn nextBitrate(current: u32) u32 { for (bitrate_choices, 0..) |value, index| { From bbf4d56ae16c3ecdd0bc6324c5385a26d0fee6f3 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:04:53 -0500 Subject: [PATCH 4/4] fix(gfn): clamp bitrate ceiling before negotiation Clamp the configured ceiling to 4000-20000 kbps once and use it for both the NVST answer and the bitrate request, widening before the bps conversion so it cannot overflow. Co-Authored-By: Claude Opus 5.5 --- src/provider/geforce_now/webrtc_session.zig | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/provider/geforce_now/webrtc_session.zig b/src/provider/geforce_now/webrtc_session.zig index b8395d8..4b16833 100644 --- a/src/provider/geforce_now/webrtc_session.zig +++ b/src/provider/geforce_now/webrtc_session.zig @@ -18,6 +18,8 @@ const c = @cImport({ const event_capacity = 64; const maximum_tracks = 8; const maximum_channels = 8; +const minimum_bitrate_kbps = 4000; +const maximum_bitrate_kbps = 20000; const EarlyIceCandidates = struct { messages: std.BoundedArray(signaling_protocol.DecodedMessage, 32) = .{}, @@ -351,11 +353,19 @@ 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; - const ceiling_kbps = c.go_handheld_ui_max_bitrate_kbps(self.ui); - if (c.rtcRequestBitrate(self.video_track, @intCast(ceiling_kbps * 1000)) >= 0) + const ceiling_bps = @as(u64, self.bitrateCeilingKbps()) * 1000; + if (c.rtcRequestBitrate(self.video_track, @intCast(ceiling_bps)) >= 0) self.bitrate_requested = true; } + fn bitrateCeilingKbps(self: *const Session) u32 { + return std.math.clamp( + c.go_handheld_ui_max_bitrate_kbps(self.ui), + minimum_bitrate_kbps, + maximum_bitrate_kbps, + ); + } + pub fn destroy(self: *Session) void { self.shutting_down.store(true, .release); if (self.peer >= 0) { @@ -432,7 +442,7 @@ pub const Session = struct { self.width, self.height, self.frames_per_second, - @intCast(c.go_handheld_ui_max_bitrate_kbps(self.ui)), + self.bitrateCeilingKbps(), ); defer self.allocator.free(nvst); try self.signaling.?.sendAnswer(narrowed_sdp, nvst);