From 7c545fe5c1adac0ac5afa2b39067a7ab7a37027a Mon Sep 17 00:00:00 2001 From: Domenico Laurito Date: Sat, 19 Sep 2026 02:27:02 +0200 Subject: [PATCH 01/13] fix(input): send real timestamps in input reports Gamepad reports carried a timestamp of 0 and the client metadata report had no timestamp or max-touchpoints value. The reference clients send performance.now() and a touchpoint count of 1. Xbox dashboards ignore the difference, but console (xHome) games do not accept the input without it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjNU9aF1G5aFtThd3oqBZT --- src/input/controller.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/input/controller.zig b/src/input/controller.zig index b75896f..55edb87 100644 --- a/src/input/controller.zig +++ b/src/input/controller.zig @@ -236,6 +236,9 @@ pub export fn go_controller_input_encode_metadata( @memset(bytes[0..15], 0); bytes[0] = 0x08; std.mem.writeInt(u32, bytes[2..6], sequence, .little); + // Same clock as the gamepad reports (the reference client uses performance.now()). + std.mem.writeInt(u64, bytes[6..14], @bitCast(@as(f64, @floatFromInt(c.SDL_GetTicks()))), .little); + bytes[14] = 1; // max touchpoints return 15; } @@ -275,7 +278,9 @@ pub export fn go_controller_input_encode( wire.encodeGamepadRaw( bytes[0..wire.PACKET_SIZE], handle.sequence, - 0.0, + // Milliseconds since start, like the reference client's performance.now(); + // xHome games use it to order input, the dashboard ignores it. + @floatFromInt(c.SDL_GetTicks()), wire.buttonMask(source_buttons), axis(handle, c.SDL_CONTROLLER_AXIS_LEFTX), left_y, From 53147aaf5215497feb30999389ef260a73306d61 Mon Sep 17 00:00:00 2001 From: Domenico Laurito Date: Sat, 19 Sep 2026 02:27:14 +0200 Subject: [PATCH 02/13] feat: add Xbox home console streaming (xHome) Adds streaming from the user's own Xbox console next to xCloud, reusing the existing WebRTC, Cedar decode and input pipeline. Cloud stays the default. - auth: request an xHome GSSV token and region base URL after the cloud login (best effort, non-fatal, same XSTS token) - catalog: list consoles from /v6/servers/home (parser, fetch, tests) - session: Offering (cloud/home) so play, state, connect, keepalive and teardown use /v5/sessions/{cloud|home}; an awake console goes straight to Provisioned without the connect step - ui: CONSOLES tab (L1/R1) listing consoles and their power state; A starts the stream - app: release flow is mode-aware; picking a console runs the same play -> WebRTC -> stream loop against the console - docs: Home streaming section in the README Tested on an RG35XX Pro (Knulli) against an Xbox Series X on the LAN. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjNU9aF1G5aFtThd3oqBZT --- README.md | 18 ++++- build.zig | 2 + src/app/release.zig | 104 +++++++++++++++++++++++++-- src/auth/xbox_auth.h | 6 ++ src/auth/xbox_auth.zig | 65 +++++++++++++++++ src/catalog/consoles.h | 37 ++++++++++ src/catalog/consoles.zig | 121 +++++++++++++++++++++++++++++++ src/catalog/consoles_parser.zig | 124 ++++++++++++++++++++++++++++++++ src/catalog/service.zig | 7 ++ src/net/json_reader.zig | 63 ++++++++++++++++ src/session/cloud_session.h | 7 ++ src/session/cloud_session.zig | 96 ++++++++++++++++++++----- src/ui/handheld_ui.h | 13 ++++ src/ui/handheld_ui.zig | 26 ++++++- src/ui/library_view.zig | 95 ++++++++++++++++++++++-- 15 files changed, 752 insertions(+), 32 deletions(-) create mode 100644 src/catalog/consoles.h create mode 100644 src/catalog/consoles.zig create mode 100644 src/catalog/consoles_parser.zig diff --git a/README.md b/README.md index 2188625..d2a7b68 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ GreenOvercast is experimental. | B | Back or cancel | Xbox B | | X | Search or delete a letter | Xbox X | | Y | Favorite a game or clear search | Xbox Y | -| L1 / R1 | Switch All / Favorites | Xbox LB / RB | +| L1 / R1 | Switch All / Favorites / Consoles | Xbox LB / RB | | L2 / R2 | Jump by first letter | Xbox LT / RT | | Start | Settings or apply search | Xbox Menu | | Select | — | Xbox View | @@ -44,6 +44,22 @@ GreenOvercast is experimental. 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. +## Home streaming + +Besides Xbox Cloud Gaming, GreenOvercast can stream from your own Xbox console +(Xbox remote play, "xHome"). It uses the same WebRTC and hardware-decode +pipeline, so cloud streaming is unchanged. + +When your account has a console, a **CONSOLES** tab appears next to Favorites. +Switch to it with L1 / R1, pick your console, and press A. The console must be +on, with remote play enabled in its settings. The list shows each console's +power state. Tested with an Xbox Series X on the local network. + +Home streaming needs no extra sign-in: it reuses the cloud login. + +If a game that was already running when you connected ignores the controller, +close it on the console and start it again from the stream. + ## Supported devices | Device | OS | Status | diff --git a/build.zig b/build.zig index d930e64..b1e6c0a 100644 --- a/build.zig +++ b/build.zig @@ -45,6 +45,7 @@ const release_zig_roots = [_]struct { .path = "src/session/cloud_session.zig", .imports = &.{.{ .name = "json_reader", .path = "src/net/json_reader.zig" }}, }, + .{ .name = "greenovercast-consoles", .path = "src/catalog/consoles.zig" }, .{ .name = "greenovercast-webrtc", .path = "src/session/webrtc_session.zig", @@ -461,6 +462,7 @@ pub fn build(b: *std.Build) void { "src/app/state.zig", "src/catalog/catalog_parser.zig", "src/catalog/catalog_search.zig", + "src/catalog/consoles_parser.zig", "src/input/wire_encoder.zig", "src/input/guide_chord.zig", "src/session/message_protocol.zig", diff --git a/src/app/release.zig b/src/app/release.zig index 0a18887..3acbfc3 100644 --- a/src/app/release.zig +++ b/src/app/release.zig @@ -6,6 +6,7 @@ const c = @cImport({ @cInclude("curl/curl.h"); @cInclude("audio_pipeline.h"); @cInclude("cloud_session.h"); + @cInclude("consoles.h"); @cInclude("controller.h"); @cInclude("handheld_ui.h"); @cInclude("sdl_platform.h"); @@ -53,12 +54,24 @@ fn debug(comptime format: []const u8, args: anytype) void { if (debugEnabled()) std.debug.print(format, args); } +// Cloud = xCloud Game Pass title; home = xHome stream from the user's own +// console. Everything after picking (play/state/connect/WebRTC/keepalive) runs +// on whichever session handle matches; cloud stays the default. +const Mode = enum { cloud, home }; + pub const Release = struct { platform: ?*c.GoSdlPlatform = null, video: ?*c.GoVideoPipeline = null, audio: ?*c.GoAudioPipeline = null, auth: ?*c.GoXboxAuth = null, cloud: ?*c.GoCloudSession = null, + home: ?*c.GoCloudSession = null, + mode: Mode = .cloud, + consoles: [c.GO_UI_MAX_CONSOLES]c.GoConsole = undefined, + console_count: usize = 0, + server_id: [64]u8 = [_]u8{0} ** 64, + home_needs_connect: bool = false, + home_provisioned: bool = false, webrtc: ?*c.GoWebrtcSession = null, catalog: ?*catalog_service.Service = null, curl_initialized: bool = false, @@ -160,6 +173,8 @@ pub const Release = struct { release.close(); return null; } + // Optional: without it the app simply stays cloud-only. + release.home = c.go_cloud_session_create_home(release.auth, release.ui()); return release; } @@ -171,6 +186,42 @@ pub const Release = struct { return c.go_sdl_platform_controller(self.platform); } + fn activeSession(self: *const Release) ?*c.GoCloudSession { + return if (self.mode == .home) self.home else self.cloud; + } + + // Best effort: no consoles (or any failure) just hides the CONSOLES tab. + fn loadConsoles(self: *Release) void { + self.console_count = 0; + c.go_handheld_ui_set_consoles(self.ui(), null, 0); + if (self.home == null) return; + const count = c.go_consoles_fetch(self.auth, self.ui(), &self.consoles, self.consoles.len); + if (count <= 0) return; + self.console_count = @intCast(count); + var rows: [c.GO_UI_MAX_CONSOLES]c.GoUiConsoleRow = undefined; + for (0..self.console_count) |index| { + rows[index] = std.mem.zeroes(c.GoUiConsoleRow); + @memcpy(&rows[index].name, &self.consoles[index].device_name); + @memcpy(&rows[index].power_state, &self.consoles[index].power_state); + } + c.go_handheld_ui_set_consoles(self.ui(), &rows, @intCast(self.console_count)); + } + + fn selectConsole(self: *Release, index: usize) Result { + if (index >= self.console_count) return .failed; + const server_id = std.mem.sliceTo(&self.consoles[index].server_id, 0); + if (server_id.len == 0 or server_id.len >= self.server_id.len) return .failed; + @memset(&self.server_id, 0); + @memcpy(self.server_id[0..server_id.len], server_id); + @memset(&self.title_id, 0); + self.mode = .home; + debug("Selected console: {s} ({s})\n", .{ + std.mem.sliceTo(&self.consoles[index].device_name, 0), + server_id, + }); + return .ok; + } + fn drawLoading(self: *Release, heading: [*c]const u8, detail: [*c]const u8, action: c.GoHandheldUiAction) void { c.go_handheld_ui_draw_loading(self.ui(), heading, detail, action); } @@ -201,7 +252,10 @@ pub const Release = struct { self.drawLoading("SIGNING IN", "REFRESHING XBOX SESSION", c.GO_HANDHELD_UI_ACTION_NONE); debug("Refreshing auth\n", .{}); return switch (c.go_xbox_auth_refresh(self.auth)) { - c.GO_XBOX_AUTH_OK => .ok, + c.GO_XBOX_AUTH_OK => result: { + self.loadConsoles(); + break :result .ok; + }, c.GO_XBOX_AUTH_REAUTH_REQUIRED => result: { self.drawLoading("SIGN IN EXPIRED", "REQUESTING A NEW DEVICE CODE", c.GO_HANDHELD_UI_ACTION_NONE); c.SDL_Delay(700); @@ -251,10 +305,12 @@ pub const Release = struct { @memset(&self.requested_title, 0); const selected = switch (selection) { .title_id => |value| value, + .console => |index| return self.selectConsole(index), .cancelled => return .cancelled, .sign_out => return .signed_out, }; if (selected.len >= self.title_id.len) return .failed; + self.mode = .cloud; @memset(&self.title_id, 0); @memcpy(self.title_id[0..selected.len], selected); return .ok; @@ -273,6 +329,18 @@ pub const Release = struct { } pub fn createSession(self: *Release) Result { + if (self.mode == .home) { + if (self.server_id[0] == 0 or self.home == null) return .failed; + self.drawLoading("STARTING STREAM", "CONNECTING TO YOUR XBOX", c.GO_HANDHELD_UI_ACTION_CANCEL); + debug("Creating home session ({s})\n", .{std.mem.sliceTo(&self.server_id, 0)}); + self.home_needs_connect = false; + self.home_provisioned = false; + if (c.go_cloud_session_start_game(self.home, @ptrCast(&self.server_id)) < 0) { + std.debug.print("Home session creation failed (is the console on and reachable?)\n", .{}); + return .failed; + } + return .ok; + } if (self.title_id[0] == 0) return .failed; self.drawLoading("STARTING GAME", "ALLOCATING CLOUD SESSION", c.GO_HANDHELD_UI_ACTION_CANCEL); debug("Creating session ({s})\n", .{std.mem.sliceTo(&self.title_id, 0)}); @@ -284,6 +352,20 @@ pub const Release = struct { } pub fn waitReady(self: *Release) Result { + if (self.mode == .home) { + // An awake console usually goes straight to Provisioned; only a + // ReadyToConnect state needs the connect step first. + debug("Waiting for the console session (ReadyToConnect/Provisioned)\n", .{}); + const state = c.go_cloud_session_wait_ready_or_provisioned(self.home, 100); + if (state < 0) { + if (c.go_handheld_ui_cancelled(self.ui()) != 0) return .cancelled; + std.debug.print("Console session never became ready\n", .{}); + return .failed; + } + self.home_needs_connect = state == 1; + self.home_provisioned = state == 2; + return .ok; + } debug("Waiting for ReadyToConnect\n", .{}); if (c.go_cloud_session_wait_for_state(self.cloud, "ReadyToConnect", 100) < 0) { if (c.go_handheld_ui_cancelled(self.ui()) != 0) return .cancelled; @@ -294,8 +376,12 @@ pub const Release = struct { } pub fn connect(self: *Release) Result { + if (self.mode == .home and !self.home_needs_connect) { + debug("Home session needs no connect step\n", .{}); + return .ok; + } debug("Connecting\n", .{}); - if (c.go_cloud_session_connect(self.cloud) < 0) { + if (c.go_cloud_session_connect(self.activeSession()) < 0) { std.debug.print("Connect failed\n", .{}); return .failed; } @@ -303,8 +389,9 @@ pub const Release = struct { } pub fn waitProvisioned(self: *Release) Result { + if (self.mode == .home and self.home_provisioned) return .ok; debug("Waiting for Provisioned\n", .{}); - if (c.go_cloud_session_wait_for_state(self.cloud, "Provisioned", 100) < 0) { + if (c.go_cloud_session_wait_for_state(self.activeSession(), "Provisioned", 100) < 0) { if (c.go_handheld_ui_cancelled(self.ui()) != 0) return .cancelled; std.debug.print("Provisioning failed\n", .{}); return .failed; @@ -318,7 +405,7 @@ pub const Release = struct { const stream_width = c.go_handheld_ui_stream_width(self.ui()); const stream_height = c.go_handheld_ui_stream_height(self.ui()); self.webrtc = c.go_webrtc_session_create( - self.cloud, + self.activeSession(), self.video, self.audio, self.controller(), @@ -381,7 +468,7 @@ pub const Release = struct { std.debug.print("Audio worker failed to start\n", .{}); return .failed; } - if (c.go_cloud_session_start_keepalive(self.cloud) < 0) { + if (c.go_cloud_session_start_keepalive(self.activeSession()) < 0) { std.debug.print("Session keepalive worker failed to start\n", .{}); return .failed; } @@ -441,7 +528,7 @@ pub const Release = struct { if (!debugEnabled()) return; const video = c.go_video_pipeline_stats(self.video); const audio = c.go_audio_pipeline_stats(self.audio); - const cloud = c.go_cloud_session_stats(self.cloud); + const cloud = c.go_cloud_session_stats(self.activeSession()); std.debug.print( "[{d}s] video_rtp={d} payload={d} rejected={d}/pt{d} aus={d} frames={d}/{d} source={d}x{d} " ++ "nals={d}/{d}/{d}/{d} ts={d} synced={d} gaps={d} missing={d} late_rtp={d} " ++ @@ -501,17 +588,21 @@ pub const Release = struct { fn closeSession(self: *Release) void { c.go_cloud_session_stop_keepalive(self.cloud); + c.go_cloud_session_stop_keepalive(self.home); c.go_webrtc_session_destroy(self.webrtc); self.webrtc = null; c.go_video_pipeline_stop(self.video); c.go_audio_pipeline_stop(self.audio); c.go_cloud_session_end(self.cloud); + c.go_cloud_session_end(self.home); } pub fn resetSession(self: *Release) Result { self.closeSession(); self.destroyMedia(); @memset(&self.title_id, 0); + @memset(&self.server_id, 0); + self.mode = .cloud; if (stopRequested()) return .cancelled; if (!self.initializeMedia()) return .failed; return .ok; @@ -522,6 +613,7 @@ pub const Release = struct { self.closeSession(); if (self.catalog) |catalog| catalog.destroy(); c.go_cloud_session_destroy(self.cloud); + c.go_cloud_session_destroy(self.home); c.go_xbox_auth_destroy(self.auth); self.destroyMedia(); c.go_sdl_platform_destroy(self.platform); diff --git a/src/auth/xbox_auth.h b/src/auth/xbox_auth.h index 9cd4464..2baae69 100644 --- a/src/auth/xbox_auth.h +++ b/src/auth/xbox_auth.h @@ -22,6 +22,12 @@ GoXboxAuthResult go_xbox_auth_refresh(GoXboxAuth* auth); int go_xbox_auth_sign_out(GoXboxAuth* auth); const char* go_xbox_auth_gssv_token(const GoXboxAuth* auth); const char* go_xbox_auth_passport_token(const GoXboxAuth* auth); +/* Home (console/LAN) streaming token and its discovered session host. Both + * return NULL when home streaming isn't available for this account (no + * linked console, unsupported region) - treat that as "no consoles", not an + * error; the cloud token/flow above is unaffected either way. */ +const char* go_xbox_auth_home_gssv_token(const GoXboxAuth* auth); +const char* go_xbox_auth_home_base_url(const GoXboxAuth* auth); void go_xbox_auth_destroy(GoXboxAuth* auth); #ifdef __cplusplus diff --git a/src/auth/xbox_auth.zig b/src/auth/xbox_auth.zig index ffa1619..691335c 100644 --- a/src/auth/xbox_auth.zig +++ b/src/auth/xbox_auth.zig @@ -14,6 +14,7 @@ const oauth_token_url = "https://login.microsoftonline.com/consumers/oauth2/v2.0 const oauth_scope = "xboxlive.signin openid profile offline_access"; const passport_scope = "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN"; const offering_url = "https://xgpuweb.gssv-play-prod.xboxlive.com/v2/login/user"; +const home_offering_url = "https://xhome.gssv-play-prod.xboxlive.com/v2/login/user"; const xbox_web_client_id = "1f907974-e22b-4810-a9de-d9647380c97e"; const auth_failed: c_int = -1; @@ -33,6 +34,16 @@ const Auth = struct { passport_token: [8192]u8 = [_]u8{0} ** 8192, token_path: [512]u8 = [_]u8{0} ** 512, token_key_path: [512]u8 = [_]u8{0} ** 512, + // xHome (console streaming over LAN/local network). Kept separate from + // the cloud fields above: a different GSSV offering ("xhome" instead of + // "xgpuweb"), a different bearer token, and - unlike cloud, which always + // talks to the fixed weu.core.gssv-play-prod.xboxlive.com host - a base + // URL that is only known once the login/user response names the + // account's default region. Missing/empty means home streaming is + // unavailable (no linked console, unsupported region, or not yet + // fetched); this must never affect the cloud fields or cloud_session. + home_gssv_token: [8192]u8 = [_]u8{0} ** 8192, + home_base_url: [256]u8 = [_]u8{0} ** 256, }; fn cString(buffer: []const u8) []const u8 { @@ -278,6 +289,8 @@ fn refresh(auth: *Auth) !c_int { std.crypto.secureZero(u8, &auth.gssv_token); std.crypto.secureZero(u8, &auth.user_token); std.crypto.secureZero(u8, &auth.passport_token); + std.crypto.secureZero(u8, &auth.home_gssv_token); + std.crypto.secureZero(u8, &auth.home_base_url); var body_buffer: [32768]u8 = undefined; var form_headers = [_][*c]const u8{"Content-Type: application/x-www-form-urlencoded"}; @@ -399,6 +412,41 @@ fn refresh(auth: *Auth) !c_int { c.go_http_response_destroy(response); debug("gsToken obtained\n", .{}); + // xHome reuses the same XSTS token as cloud (both are authorized against + // the "http://gssv.xboxlive.com/" relying party above) - only the + // offeringId and host differ. This is best-effort and never fatal: an + // account with no console linked, or in a region xHome doesn't cover, + // simply won't get a home_gssv_token, and cloud keeps working exactly as + // before. + const home_offering_body = try std.fmt.bufPrintZ( + &body_buffer, + "{{\"token\":\"{s}\",\"offeringId\":\"xhome\"}}", + .{xsts}, + ); + response = c.go_http_request( + "POST", + home_offering_url, + home_offering_body.ptr, + @ptrCast(&gssv_headers), + gssv_headers.len, + ); + if (response != null and response.*.status == 200) { + if (responseData(response)) |home_data| { + _ = jsonString(home_data, "gsToken", &auth.home_gssv_token) catch {}; + _ = json.parseDefaultRegionBaseUri(home_data, &auth.home_base_url) catch {}; + } + if (cString(&auth.home_gssv_token).len != 0 and cString(&auth.home_base_url).len != 0) { + debug("Home gsToken obtained ({s})\n", .{cString(&auth.home_base_url)}); + } else { + std.crypto.secureZero(u8, &auth.home_gssv_token); + std.crypto.secureZero(u8, &auth.home_base_url); + debug("Home offering response was missing a token or default region\n", .{}); + } + } else { + debug("Home offering unavailable (no linked console or unsupported region)\n", .{}); + } + c.go_http_response_destroy(response); + const passport_body = try buildForm(&body_buffer, &.{ .{ .key = "client_id", .value = cString(&auth.client_id) }, .{ .key = "grant_type", .value = "refresh_token" }, @@ -487,6 +535,8 @@ pub export fn go_xbox_auth_sign_out(auth: ?*Auth) c_int { std.crypto.secureZero(u8, &handle.user_token); std.crypto.secureZero(u8, &handle.refresh_token); std.crypto.secureZero(u8, &handle.passport_token); + std.crypto.secureZero(u8, &handle.home_gssv_token); + std.crypto.secureZero(u8, &handle.home_base_url); return c.go_token_store_delete( @ptrCast(&handle.token_path), @ptrCast(&handle.token_key_path), @@ -503,6 +553,21 @@ pub export fn go_xbox_auth_passport_token(auth: ?*const Auth) [*c]const u8 { return @ptrCast(&handle.passport_token); } +// Both return null when home streaming isn't available for this account +// (see the comment above the Auth.home_gssv_token field) - callers must +// treat that as "no consoles to show", not as an error. +pub export fn go_xbox_auth_home_gssv_token(auth: ?*const Auth) [*c]const u8 { + const handle = auth orelse return null; + if (handle.home_gssv_token[0] == 0) return null; + return @ptrCast(&handle.home_gssv_token); +} + +pub export fn go_xbox_auth_home_base_url(auth: ?*const Auth) [*c]const u8 { + const handle = auth orelse return null; + if (handle.home_base_url[0] == 0) return null; + return @ptrCast(&handle.home_base_url); +} + pub export fn go_xbox_auth_destroy(auth: ?*Auth) void { const handle = auth orelse return; std.crypto.secureZero(u8, std.mem.asBytes(handle)); diff --git a/src/catalog/consoles.h b/src/catalog/consoles.h new file mode 100644 index 0000000..1c5bde2 --- /dev/null +++ b/src/catalog/consoles.h @@ -0,0 +1,37 @@ +#ifndef GREENOVERCAST_CONSOLES_H +#define GREENOVERCAST_CONSOLES_H + +#include +#include + +#include "handheld_ui.h" +#include "xbox_auth.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Mirrors consoles_parser.zig's Console (checked at compile time in + * consoles.zig). All strings are NUL-terminated. */ +typedef struct { + char device_name[128]; + char server_id[64]; + char power_state[32]; + char console_type[32]; + char play_path[192]; + bool out_of_home_warning; + bool wireless_warning; +} GoConsole; + +/* Fetches the account's consoles (GET /v6/servers/home, home token + host from + * xbox_auth). Returns how many were written to `out` (0 when home streaming + * is unavailable for this account, e.g. no linked console), or -1 on request + * failure. Neither case is fatal: the cloud flow is unaffected. `ui` may be + * NULL; it only sizes the X-MS-Device-Info header. */ +int go_consoles_fetch(GoXboxAuth* auth, GoHandheldUi* ui, GoConsole* out, size_t capacity); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/catalog/consoles.zig b/src/catalog/consoles.zig new file mode 100644 index 0000000..2f44080 --- /dev/null +++ b/src/catalog/consoles.zig @@ -0,0 +1,121 @@ +// xHome console list: GET /v6/servers/home with the home token/host that +// xbox_auth discovers at login, parsed into fixed-size Console records that +// the app shows on the CONSOLES tab. Failure or "home streaming unavailable +// for this account" is never an error for the caller - it just means no +// consoles, and cloud streaming is unaffected. Prints the list when +// GREENOVERCAST_DEBUG is set. +const std = @import("std"); +const parser = @import("consoles_parser.zig"); + +const c = @cImport({ + @cInclude("consoles.h"); + @cInclude("handheld_ui.h"); + @cInclude("http_client.h"); + @cInclude("xbox_auth.h"); +}); + +comptime { + if (@sizeOf(parser.Console) != @sizeOf(c.GoConsole)) + @compileError("console ABI mismatch"); +} + +fn cString(pointer: [*c]const u8) ?[]const u8 { + if (pointer == null) return null; + return std.mem.span(@as([*:0]const u8, @ptrCast(pointer))); +} + +fn debugEnabled() bool { + return std.posix.getenv("GREENOVERCAST_DEBUG") != null; +} + +fn debug(comptime format: []const u8, args: anytype) void { + if (debugEnabled()) std.debug.print(format, args); +} + +// Mirrors cloud_session.zig's private request() (same headers, same +// X-MS-Device-Info shape) but against the home base URL/token discovered by +// xbox_auth's refresh(), since go_cloud_session_request is hardwired to the +// cloud token and must stay that way. +fn fetchConsolesJson( + auth: *c.GoXboxAuth, + ui: ?*c.GoHandheldUi, + url_buffer: []u8, +) ?[*c]c.GoHttpResponse { + const home_token = cString(c.go_xbox_auth_home_gssv_token(auth)) orelse return null; + const home_base_url = cString(c.go_xbox_auth_home_base_url(auth)) orelse return null; + const url = std.fmt.bufPrintZ(url_buffer, "{s}/v6/servers/home", .{home_base_url}) catch return null; + + var auth_header_buffer: [16384]u8 = undefined; + const auth_header = std.fmt.bufPrintZ( + &auth_header_buffer, + "Authorization: Bearer {s}", + .{home_token}, + ) catch return null; + + const stream_width = if (ui) |handle| c.go_handheld_ui_stream_width(handle) else 1280; + const stream_height = if (ui) |handle| c.go_handheld_ui_stream_height(handle) else 720; + var device_info_buffer: [1024]u8 = undefined; + const device_info_header = std.fmt.bufPrintZ( + &device_info_buffer, + "X-MS-Device-Info: {{\"appInfo\":{{\"env\":{{\"clientAppId\":\"www.xbox.com\"," ++ + "\"clientAppType\":\"browser\",\"clientAppVersion\":\"26.1.97\"," ++ + "\"clientSdkVersion\":\"10.3.7\",\"httpEnvironment\":\"prod\",\"sdkInstallId\":\"\"}}}}," ++ + "\"dev\":{{\"hw\":{{\"make\":\"Microsoft\",\"model\":\"unknown\",\"sdktype\":\"web\"}}," ++ + "\"os\":{{\"name\":\"android\",\"ver\":\"22631.2715\",\"platform\":\"desktop\"}}," ++ + "\"displayInfo\":{{\"dimensions\":{{\"widthInPixels\":{d},\"heightInPixels\":{d}}}," ++ + "\"pixelDensity\":{{\"dpiX\":1,\"dpiY\":1}}}},\"browser\":{{\"browserName\":\"chrome\"," ++ + "\"browserVersion\":\"140.0.3485.54\"}}}}}}", + .{ stream_width, stream_height }, + ) catch return null; + + var headers: [3][*c]const u8 = .{ + auth_header.ptr, + "Accept: application/json", + device_info_header.ptr, + }; + return c.go_http_request("GET", url.ptr, null, @ptrCast(&headers), headers.len); +} + +fn fetchConsoles(auth: *c.GoXboxAuth, ui: ?*c.GoHandheldUi, consoles: []parser.Console) !usize { + var url_buffer: [512]u8 = undefined; + const response = fetchConsolesJson(auth, ui, &url_buffer) orelse { + debug("Home streaming unavailable - no console list\n", .{}); + return 0; + }; + defer c.go_http_response_destroy(response); + if (c.go_http_response_succeeded(response) == 0) return error.RequestFailed; + if (response == null or response.*.data == null) return error.InvalidResponse; + + const count = try parser.parseConsoles(response.*.data[0..response.*.len], consoles); + debug("Consoles found: {d}\n", .{count}); + for (consoles[0..count]) |console| { + debug( + " - {s} ({s}) power={s} type={s}{s}\n", + .{ + parser.cString(&console.device_name), + parser.cString(&console.server_id), + parser.cString(&console.power_state), + parser.cString(&console.console_type), + if (console.wireless_warning) " [wireless]" else "", + }, + ); + } + return count; +} + +// Returns the number of consoles written to `out` (0 when home streaming is +// unavailable), or -1 if the request failed. Callers should treat both as +// "no consoles to show" - never as a reason to block the cloud library. +pub export fn go_consoles_fetch( + auth: ?*c.GoXboxAuth, + ui: ?*c.GoHandheldUi, + out: [*]parser.Console, + capacity: usize, +) c_int { + const handle = auth orelse return -1; + const count = fetchConsoles(handle, ui, out[0..capacity]) catch |err| { + debug("Console list failed: {s}\n", .{@errorName(err)}); + return -1; + }; + return @intCast(count); +} diff --git a/src/catalog/consoles_parser.zig b/src/catalog/consoles_parser.zig new file mode 100644 index 0000000..f6b4331 --- /dev/null +++ b/src/catalog/consoles_parser.zig @@ -0,0 +1,124 @@ +const std = @import("std"); + +// Field shapes verified against GET /v6/servers/home responses as consumed +// by xbox-xcloud-player (unknownskl) - see ConsolesResponse/Console there. +pub const device_name_capacity = 128; +pub const server_id_capacity = 64; +pub const power_state_capacity = 32; +pub const console_type_capacity = 32; +pub const play_path_capacity = 192; + +pub const Console = extern struct { + device_name: [device_name_capacity]u8, + server_id: [server_id_capacity]u8, + power_state: [power_state_capacity]u8, + console_type: [console_type_capacity]u8, + play_path: [play_path_capacity]u8, + out_of_home_warning: bool, + wireless_warning: bool, +}; + +pub fn cString(bytes: []const u8) []const u8 { + return bytes[0 .. std.mem.indexOfScalar(u8, bytes, 0) orelse bytes.len]; +} + +fn writeCString(destination: []u8, text: []const u8) bool { + if (text.len == 0 or text.len >= destination.len) return false; + @memset(destination, 0); + @memcpy(destination[0..text.len], text); + return true; +} + +fn objectString(object: std.json.ObjectMap, key: []const u8) ?[]const u8 { + const value = object.get(key) orelse return null; + return switch (value) { + .string => |text| text, + else => null, + }; +} + +fn objectBool(object: std.json.ObjectMap, key: []const u8) bool { + const value = object.get(key) orelse return false; + return switch (value) { + .bool => |flag| flag, + else => false, + }; +} + +fn parseConsole(object: std.json.ObjectMap) ?Console { + const server_id = objectString(object, "serverId") orelse return null; + if (server_id.len == 0) return null; + var console = std.mem.zeroes(Console); + if (!writeCString(&console.server_id, server_id)) return null; + if (objectString(object, "deviceName")) |value| _ = writeCString(&console.device_name, value); + if (objectString(object, "powerState")) |value| _ = writeCString(&console.power_state, value); + if (objectString(object, "consoleType")) |value| _ = writeCString(&console.console_type, value); + if (objectString(object, "playPath")) |value| _ = writeCString(&console.play_path, value); + console.out_of_home_warning = objectBool(object, "outOfHomeWarning"); + console.wireless_warning = objectBool(object, "wirelessWarning"); + return console; +} + +pub fn parseConsoles(data: []const u8, consoles: []Console) !usize { + const parsed = try std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, data, .{}); + defer parsed.deinit(); + const root = switch (parsed.value) { + .object => |object| object, + else => return error.MissingField, + }; + const results = switch (root.get("results") orelse return error.MissingField) { + .array => |array| array.items, + else => return error.MissingField, + }; + var count: usize = 0; + for (results) |item| { + if (count >= consoles.len) break; + const object = switch (item) { + .object => |value| value, + else => continue, + }; + if (parseConsole(object)) |console| { + consoles[count] = console; + count += 1; + } + } + return count; +} + +export fn go_consoles_parse(data: [*]const u8, length: usize, consoles: [*]Console, capacity: usize) c_int { + const count = parseConsoles(data[0..length], consoles[0..capacity]) catch |err| { + std.debug.print("Consoles JSON parse failed: {s}\n", .{@errorName(err)}); + return -1; + }; + return @intCast(count); +} + +test "parses consoles and skips entries without a serverId" { + const fixture = + \\{"totalItems":2,"results":[ + \\ {"deviceName":"Living Room","serverId":"abc-123","powerState":"On", + \\ "consoleType":"XboxSeriesX","playPath":"/v5/sessions/home/play", + \\ "outOfHomeWarning":false,"wirelessWarning":true,"isDevKit":false}, + \\ {"deviceName":"No id","powerState":"Off"} + \\],"continuationToken":null} + ; + var consoles = std.mem.zeroes([4]Console); + const count = try parseConsoles(fixture, &consoles); + try std.testing.expectEqual(@as(usize, 1), count); + try std.testing.expectEqualStrings("Living Room", cString(&consoles[0].device_name)); + try std.testing.expectEqualStrings("abc-123", cString(&consoles[0].server_id)); + try std.testing.expectEqualStrings("On", cString(&consoles[0].power_state)); + try std.testing.expectEqualStrings("XboxSeriesX", cString(&consoles[0].console_type)); + try std.testing.expect(!consoles[0].out_of_home_warning); + try std.testing.expect(consoles[0].wireless_warning); +} + +test "console parsing is bounded and rejects malformed JSON" { + var consoles = std.mem.zeroes([1]Console); + const fixture = + \\{"results":[{"serverId":"one"},{"serverId":"two"}]} + ; + try std.testing.expectEqual(@as(usize, 1), try parseConsoles(fixture, &consoles)); + try std.testing.expectError(error.UnexpectedEndOfInput, parseConsoles("{", &consoles)); + try std.testing.expectError(error.MissingField, parseConsoles("{}", &consoles)); +} diff --git a/src/catalog/service.zig b/src/catalog/service.zig index 184b6a9..7f3bf20 100644 --- a/src/catalog/service.zig +++ b/src/catalog/service.zig @@ -16,6 +16,8 @@ pub const LoadResult = enum { pub const PickResult = union(enum) { title_id: []const u8, + // Index into the console list previously given to the UI (xHome). + console: usize, cancelled, sign_out, }; @@ -117,6 +119,11 @@ pub const Service = struct { ); if (selected == c.GO_HANDHELD_UI_PICK_SIGN_OUT) return .sign_out; if (selected == c.GO_HANDHELD_UI_PICK_CANCELLED) return .cancelled; + if (selected <= c.GO_HANDHELD_UI_PICK_CONSOLE_BASE) { + const index: usize = @intCast(c.GO_HANDHELD_UI_PICK_CONSOLE_BASE - selected); + std.debug.print("Selected console index: {d}\n", .{index}); + return .{ .console = index }; + } if (selected < 0) return error.InvalidSelection; if (selected >= self.count) return error.InvalidSelection; const title = &titles[@intCast(selected)]; diff --git a/src/net/json_reader.zig b/src/net/json_reader.zig index f57b39f..fe105e2 100644 --- a/src/net/json_reader.zig +++ b/src/net/json_reader.zig @@ -102,6 +102,48 @@ pub fn parseUnsigned(data: []const u8, key: []const u8) !u32 { error.MissingField; } +// GSSV login/user responses (both xgpuweb/cloud and xhome offerings) carry +// several candidate regions under offeringSettings.regions[]; unlike the +// cloud offering, xHome does not use a fixed well-known host, so the caller +// must read the region marked isDefault and use its baseUri for every +// subsequent call on that session (state/connect/sdp/ice/keepalive/delete). +pub fn parseDefaultRegionBaseUri(data: []const u8, output: []u8) !usize { + const parsed = try std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, data, .{}); + defer parsed.deinit(); + const root = switch (parsed.value) { + .object => |object| object, + else => return error.MissingField, + }; + const offering_settings = switch (root.get("offeringSettings") orelse return error.MissingField) { + .object => |object| object, + else => return error.MissingField, + }; + const regions = switch (offering_settings.get("regions") orelse return error.MissingField) { + .array => |array| array.items, + else => return error.MissingField, + }; + for (regions) |region| { + const object = switch (region) { + .object => |value| value, + else => continue, + }; + const is_default = switch (object.get("isDefault") orelse continue) { + .bool => |value| value, + else => false, + }; + if (!is_default) continue; + const base_uri = switch (object.get("baseUri") orelse continue) { + .string => |text| text, + else => continue, + }; + if (base_uri.len >= output.len) return error.NoSpaceLeft; + @memcpy(output[0..base_uri.len], base_uri); + output[base_uri.len] = 0; + return base_uri.len; + } + return error.MissingField; +} + test "copies decoded strings from direct and embedded JSON" { var output: [128]u8 = undefined; const direct = @@ -129,3 +171,24 @@ test "reads bounded unsigned integer fields" { try std.testing.expectError(error.MissingField, parseUnsigned("{\"expires_in\":-1}", "expires_in")); try std.testing.expectError(error.MissingField, parseUnsigned("{\"expires_in\":\"900\"}", "expires_in")); } + +test "picks the default region's baseUri among several candidates" { + var output: [128]u8 = undefined; + const fixture = + \\{"gsToken":"abc","offeringSettings":{"regions":[ + \\ {"name":"WestEurope","baseUri":"https://weu.core.gssv-play-prodxhome.xboxlive.com","isDefault":false}, + \\ {"name":"UKSouth","baseUri":"https://uks.core.gssv-play-prodxhome.xboxlive.com","isDefault":true} + \\]}} + ; + const length = try parseDefaultRegionBaseUri(fixture, &output); + try std.testing.expectEqualStrings("https://uks.core.gssv-play-prodxhome.xboxlive.com", output[0..length]); +} + +test "default region lookup rejects a response with no default region" { + var output: [128]u8 = undefined; + const fixture = + \\{"offeringSettings":{"regions":[{"name":"WestEurope","baseUri":"https://weu.example","isDefault":false}]}} + ; + try std.testing.expectError(error.MissingField, parseDefaultRegionBaseUri(fixture, &output)); + try std.testing.expectError(error.MissingField, parseDefaultRegionBaseUri("{}", &output)); +} diff --git a/src/session/cloud_session.h b/src/session/cloud_session.h index 3794e3f..a906824 100644 --- a/src/session/cloud_session.h +++ b/src/session/cloud_session.h @@ -17,6 +17,10 @@ typedef struct { } GoCloudSessionStats; GoCloudSession* go_cloud_session_create(GoXboxAuth* auth, GoHandheldUi* ui); +/* Same session type, but for xHome console streaming: uses the home token and + * the host discovered at login, and go_cloud_session_start_game() takes the + * console's serverId instead of a titleId. */ +GoCloudSession* go_cloud_session_create_home(GoXboxAuth* auth, GoHandheldUi* ui); const char* go_cloud_session_base_url(const GoCloudSession* session); const char* go_cloud_session_path(const GoCloudSession* session); GoHttpResponse* go_cloud_session_request(GoCloudSession* session, const char* method, @@ -24,6 +28,9 @@ GoHttpResponse* go_cloud_session_request(GoCloudSession* session, const char* me const char** extra_headers, int extra_header_count); int go_cloud_session_start_game(GoCloudSession* session, const char* title_id); int go_cloud_session_wait_for_state(GoCloudSession* session, const char* target, int max_polls); +/* xHome only: returns 1 when the session needs go_cloud_session_connect() + * first (ReadyToConnect), 2 when it is already Provisioned, -1 on failure. */ +int go_cloud_session_wait_ready_or_provisioned(GoCloudSession* session, int max_polls); int go_cloud_session_connect(GoCloudSession* session); int go_cloud_session_start_keepalive(GoCloudSession* session); void go_cloud_session_stop_keepalive(GoCloudSession* session); diff --git a/src/session/cloud_session.zig b/src/session/cloud_session.zig index b28f153..1777ddc 100644 --- a/src/session/cloud_session.zig +++ b/src/session/cloud_session.zig @@ -25,9 +25,16 @@ const Stats = extern struct { keepalive_failures: c_int, }; +// "cloud" is xCloud Game Pass streaming (fixed host, titleId); "home" is +// console streaming to the user's own Xbox (xHome: host discovered at login, +// serverId). Everything after play (state/connect/sdp/ice/keepalive/delete) +// is the same session-path protocol for both, so one Session type serves both. +const Offering = enum(c_int) { cloud = 0, home = 1 }; + const Session = struct { auth: *c.GoXboxAuth, ui: *c.GoHandheldUi, + offering: Offering = .cloud, session_path: [256]u8 = [_]u8{0} ** 256, keepalive_thread: ?std.Thread = null, keepalive_stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), @@ -46,6 +53,13 @@ fn bufferString(buffer: []const u8) []const u8 { return buffer[0 .. std.mem.indexOfScalar(u8, buffer, 0) orelse buffer.len]; } +fn baseUrl(session: *const Session) ?[]const u8 { + return switch (session.offering) { + .cloud => base_url, + .home => cString(c.go_xbox_auth_home_base_url(session.auth)), + }; +} + fn responseData(response: [*c]c.GoHttpResponse) ?[]const u8 { if (response == null or response.*.data == null) return null; return response.*.data[0..response.*.len]; @@ -70,7 +84,10 @@ fn request( ) [*c]c.GoHttpResponse { if (method == null or url == null or extra_header_count < 0 or extra_header_count > 4) return null; - const token = cString(c.go_xbox_auth_gssv_token(session.auth)) orelse return null; + const token = cString(switch (session.offering) { + .cloud => c.go_xbox_auth_gssv_token(session.auth), + .home => c.go_xbox_auth_home_gssv_token(session.auth), + }) orelse return null; var auth_header_buffer: [16384]u8 = undefined; const auth_header = std.fmt.bufPrintZ( &auth_header_buffer, @@ -107,8 +124,17 @@ fn request( fn startGame(session: *Session, title_id: []const u8) !void { if (title_id.len == 0) return error.InvalidTitle; if (bufferString(&session.session_path).len != 0) return error.ActiveSession; + const host = baseUrl(session) orelse return error.MissingBaseUrl; var url_buffer: [512]u8 = undefined; - const url = try std.fmt.bufPrintZ(&url_buffer, "{s}/v5/sessions/cloud/play", .{base_url}); + const url = try std.fmt.bufPrintZ( + &url_buffer, + "{s}/v5/sessions/{s}/play", + .{ host, if (session.offering == .home) "home" else "cloud" }, + ); + // For cloud the id names a title; for home it names the console + // (serverId) and titleId stays empty - same body shape either way. + const title_value = if (session.offering == .cloud) title_id else ""; + const server_value = if (session.offering == .home) title_id else ""; var body_buffer: [4096]u8 = undefined; const body = try std.fmt.bufPrintZ( &body_buffer, @@ -117,8 +143,8 @@ fn startGame(session: *Session, title_id: []const u8) !void { "\"enableOptionalDataCollection\":false,\"enableTextToSpeech\":false," ++ "\"highContrast\":0,\"locale\":\"en-US\",\"useIceConnection\":false," ++ "\"timezoneOffsetMinutes\":120,\"sdkType\":\"web\",\"osName\":\"android\"}}," ++ - "\"serverId\":\"\",\"fallbackRegionNames\":[]}}", - .{title_id}, + "\"serverId\":\"{s}\",\"fallbackRegionNames\":[]}}", + .{ title_value, server_value }, ); var headers = [_][*c]const u8{"Content-Type: application/json"}; const response = request(session, "POST", url.ptr, body.ptr, @ptrCast(&headers), headers.len); @@ -130,19 +156,29 @@ fn startGame(session: *Session, title_id: []const u8) !void { } fn waitForState(session: *Session, target: []const u8, max_polls: usize) !void { - if (bufferString(&session.session_path).len == 0 or target.len == 0 or max_polls == 0) + _ = try waitForAnyState(session, &.{target}, max_polls); +} + +// Polls the session state until it equals one of `targets` and returns that +// target's index. Cloud sessions always pass a single target (ReadyToConnect, +// then Provisioned after connect); xHome sessions on an awake console usually +// go straight to Provisioned without ever needing the connect step, so the +// caller lists both and acts on which one it got. +fn waitForAnyState(session: *Session, targets: []const []const u8, max_polls: usize) !usize { + if (bufferString(&session.session_path).len == 0 or targets.len == 0 or max_polls == 0) return error.InvalidStateRequest; - var target_buffer: [64]u8 = [_]u8{0} ** 64; - if (target.len >= target_buffer.len) return error.InvalidStateRequest; - @memcpy(target_buffer[0..target.len], target); + for (targets) |target| { + if (target.len == 0 or target.len >= 64) return error.InvalidStateRequest; + } var poll: usize = 0; while (poll < max_polls) : (poll += 1) { + const host = baseUrl(session) orelse return error.MissingBaseUrl; var url_buffer: [512]u8 = undefined; const url = try std.fmt.bufPrintZ( &url_buffer, "{s}/{s}/state", - .{ base_url, bufferString(&session.session_path) }, + .{ host, bufferString(&session.session_path) }, ); const response = request(session, "GET", url.ptr, null, null, 0); if (c.go_http_response_succeeded(response) == 0) { @@ -157,9 +193,13 @@ fn waitForState(session: *Session, target: []const u8, max_polls: usize) !void { const state = if (data) |payload| jsonString(payload, "state", &state_buffer) catch null else null; const details = if (data) |payload| jsonString(payload, "errorDetails", &error_buffer) catch null else null; if (state) |value| debug("state: {s}\n", .{value}); - if (state != null and std.mem.eql(u8, state.?, target)) { - c.go_http_response_destroy(response); - return; + if (state) |value| { + for (targets, 0..) |target, index| { + if (std.mem.eql(u8, value, target)) { + c.go_http_response_destroy(response); + return index; + } + } } if (state != null and (std.mem.eql(u8, state.?, "Failed") or std.mem.eql(u8, state.?, "Expired"))) @@ -178,11 +218,12 @@ fn connect(session: *Session) !void { if (bufferString(&session.session_path).len == 0) return error.MissingSession; const passport_token = cString(c.go_xbox_auth_passport_token(session.auth)) orelse return error.MissingPassportToken; + const host = baseUrl(session) orelse return error.MissingBaseUrl; var url_buffer: [512]u8 = undefined; const url = try std.fmt.bufPrintZ( &url_buffer, "{s}/{s}/connect", - .{ base_url, bufferString(&session.session_path) }, + .{ host, bufferString(&session.session_path) }, ); var body_buffer: [16384]u8 = undefined; const body = try std.fmt.bufPrintZ(&body_buffer, "{{\"userToken\":\"{s}\"}}", .{passport_token}); @@ -193,11 +234,12 @@ fn connect(session: *Session) !void { } fn sendKeepalive(session: *Session) !void { + const host = baseUrl(session) orelse return error.MissingBaseUrl; var url_buffer: [512]u8 = undefined; const url = try std.fmt.bufPrintZ( &url_buffer, "{s}/{s}/keepalive", - .{ base_url, bufferString(&session.session_path) }, + .{ host, bufferString(&session.session_path) }, ); const response = request(session, "POST", url.ptr, null, null, 0); defer c.go_http_response_destroy(response); @@ -252,8 +294,9 @@ fn end(session: *Session) void { const path = bufferString(&session.session_path); if (path.len == 0) return; + const host = baseUrl(session) orelse return; var url_buffer: [512]u8 = undefined; - if (std.fmt.bufPrintZ(&url_buffer, "{s}/{s}", .{ base_url, path })) |url| { + if (std.fmt.bufPrintZ(&url_buffer, "{s}/{s}", .{ host, path })) |url| { const response = request(session, "DELETE", url.ptr, null, null, 0); c.go_http_response_destroy(response); } else |_| {} @@ -268,9 +311,18 @@ pub export fn go_cloud_session_create(auth: ?*c.GoXboxAuth, ui: ?*c.GoHandheldUi return session; } +pub export fn go_cloud_session_create_home(auth: ?*c.GoXboxAuth, ui: ?*c.GoHandheldUi) ?*Session { + const session = go_cloud_session_create(auth, ui) orelse return null; + session.offering = .home; + return session; +} + pub export fn go_cloud_session_base_url(session: ?*const Session) [*c]const u8 { - if (session == null) return null; - return base_url; + const handle = session orelse return null; + return switch (handle.offering) { + .cloud => base_url, + .home => c.go_xbox_auth_home_base_url(handle.auth), + }; } pub export fn go_cloud_session_path(session: ?*const Session) [*c]const u8 { @@ -309,6 +361,16 @@ pub export fn go_cloud_session_wait_for_state( return 0; } +// xHome: waits for ReadyToConnect or Provisioned. Returns 1 when the session +// wants the connect step first, 2 when it is already provisioned, -1 on +// failure/cancel/timeout. +pub export fn go_cloud_session_wait_ready_or_provisioned(session: ?*Session, max_polls: c_int) c_int { + const handle = session orelse return -1; + if (max_polls <= 0) return -1; + const index = waitForAnyState(handle, &.{ "ReadyToConnect", "Provisioned" }, @intCast(max_polls)) catch return -1; + return @as(c_int, @intCast(index)) + 1; +} + pub export fn go_cloud_session_connect(session: ?*Session) c_int { connect(session orelse return -1) catch return -1; return 0; diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h index 8f53382..f74f366 100644 --- a/src/ui/handheld_ui.h +++ b/src/ui/handheld_ui.h @@ -16,8 +16,18 @@ typedef int (*GoUiStopRequested)(void* context); enum { GO_HANDHELD_UI_PICK_CANCELLED = -1, GO_HANDHELD_UI_PICK_SIGN_OUT = -2, + /* A console picked on the CONSOLES tab is reported as + * GO_HANDHELD_UI_PICK_CONSOLE_BASE - index (so -100, -101, ...). */ + GO_HANDHELD_UI_PICK_CONSOLE_BASE = -100, }; +#define GO_UI_MAX_CONSOLES 8 + +typedef struct { + char name[128]; + char power_state[32]; +} GoUiConsoleRow; + typedef enum { GO_HANDHELD_UI_ACTION_NONE = 0, GO_HANDHELD_UI_ACTION_BACK, @@ -39,6 +49,9 @@ int go_handheld_ui_sign_in_action(GoHandheldUi* ui); int go_handheld_ui_wait_for_retry(GoHandheldUi* ui, const char* heading, const char* detail); int go_handheld_ui_pick_title(GoHandheldUi* ui, const GoCatalogTitle* titles, int count, const char* requested); +/* Provides the xHome consoles for the CONSOLES tab (copied; count is capped at + * GO_UI_MAX_CONSOLES). count <= 0 hides the tab. */ +void go_handheld_ui_set_consoles(GoHandheldUi* ui, const GoUiConsoleRow* rows, int count); 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); diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 1348151..f57da9c 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -29,6 +29,8 @@ const Ui = struct { cancelled: bool = false, stream_width: u32, stream_height: u32, + console_rows: [c.GO_UI_MAX_CONSOLES]library.ConsoleRow = undefined, + console_count: usize = 0, }; const ArtworkSelection = struct { @@ -390,7 +392,11 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in ui.cancelled = false; const indices = std.heap.c_allocator.alloc(usize, titles.len) catch return c.GO_HANDHELD_UI_PICK_CANCELLED; defer std.heap.c_allocator.free(indices); - var view = library.View{ .titles = titles, .indices = indices }; + var view = library.View{ + .titles = titles, + .indices = indices, + .consoles = ui.console_rows[0..ui.console_count], + }; view.rebuild(&ui.settings, library.requestedTitle(titles, requested)); var repeat = navigation.Repeater{}; var horizontal_latch = navigation.AxisLatch{}; @@ -416,7 +422,10 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in return c.GO_HANDHELD_UI_PICK_CANCELLED; } if (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event)) switch (semanticButton(ui, event.cbutton.button)) { - c.SDL_CONTROLLER_BUTTON_A => if (view.selectedTitleIndex()) |title_index| { + c.SDL_CONTROLLER_BUTTON_A => if (view.collection == .consoles) { + if (view.console_selected < view.consoles.len) + return c.GO_HANDHELD_UI_PICK_CONSOLE_BASE - @as(c_int, @intCast(view.console_selected)); + } else if (view.selectedTitleIndex()) |title_index| { return @intCast(title_index); }, c.SDL_CONTROLLER_BUTTON_B => { @@ -425,6 +434,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in return c.GO_HANDHELD_UI_PICK_CANCELLED; }, c.SDL_CONTROLLER_BUTTON_X => { + if (view.collection == .consoles) continue; const preserve = view.selectedTitleIndex(); const result = runSearchKeyboard(ui, &view); if (result < 0) return c.GO_HANDHELD_UI_PICK_CANCELLED; @@ -657,6 +667,18 @@ pub export fn go_handheld_ui_pick_title( return pickTitle(ui orelse return c.GO_HANDHELD_UI_PICK_CANCELLED, parsed_titles[0..@intCast(count)], pointerString(requested) orelse ""); } +pub export fn go_handheld_ui_set_consoles(ui: ?*Ui, rows: [*c]const c.GoUiConsoleRow, count: c_int) void { + const handle = ui orelse return; + handle.console_count = 0; + if (rows == null or count <= 0) return; + const limit: usize = @min(@as(usize, @intCast(count)), c.GO_UI_MAX_CONSOLES); + for (0..limit) |index| { + @memcpy(&handle.console_rows[index].name, &rows[index].name); + @memcpy(&handle.console_rows[index].power_state, &rows[index].power_state); + } + handle.console_count = limit; +} + pub export fn go_handheld_ui_cancelled(ui: ?*const Ui) c_int { return @intFromBool(if (ui) |handle| handle.cancelled else false); } diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig index a01ecf8..a965c02 100644 --- a/src/ui/library_view.zig +++ b/src/ui/library_view.zig @@ -17,6 +17,15 @@ pub const Title = c.GoCatalogTitle; pub const Collection = enum { all, favorites, + // xHome: the user's own consoles instead of cloud titles. Only reachable + // when at least one console was found (see View.consoles). + consoles, +}; + +// Layout-compatible with GoUiConsoleRow in handheld_ui.h. +pub const ConsoleRow = extern struct { + name: [128]u8, + power_state: [32]u8, }; pub const View = struct { @@ -26,10 +35,15 @@ pub const View = struct { selected: usize = 0, collection: Collection = .all, query: [query_capacity]u8 = [_]u8{0} ** query_capacity, + consoles: []const ConsoleRow = &.{}, + console_selected: usize = 0, pub fn rebuild(self: *View, store: *const settings.Store, preserve_title_index: ?usize) void { self.count = 0; self.selected = 0; + // The consoles tab lists consoles, not titles: keep the title list empty + // so nothing title-related (play/favorite/artwork/letter jump) can act. + if (self.collection == .consoles) return; for (self.titles, 0..) |*title, index| { if (self.collection == .favorites and !isFavorite(store, title)) continue; if (!search.matches(titleName(title), std.mem.sliceTo(&self.query, 0))) continue; @@ -51,6 +65,14 @@ pub const View = struct { } pub fn move(self: *View, direction: i8) void { + if (self.collection == .consoles) { + if (self.consoles.len == 0) return; + if (direction < 0) + self.console_selected = if (self.console_selected == 0) self.consoles.len - 1 else self.console_selected - 1 + else + self.console_selected = if (self.console_selected + 1 >= self.consoles.len) 0 else self.console_selected + 1; + return; + } if (self.count == 0) return; if (direction < 0) self.selected = if (self.selected == 0) self.count - 1 else self.selected - 1 @@ -60,10 +82,17 @@ pub const View = struct { pub fn switchCollection(self: *View, store: *const settings.Store, direction: i8) void { const preserve = self.selectedTitleIndex(); - self.collection = if (direction < 0) - (if (self.collection == .all) .favorites else .all) - else - (if (self.collection == .favorites) .all else .favorites); + // Tab order: ALL -> FAVORITES -> CONSOLES (only when consoles exist) -> ALL. + const has_consoles = self.consoles.len > 0; + self.collection = if (direction < 0) switch (self.collection) { + .all => if (has_consoles) Collection.consoles else Collection.favorites, + .favorites => Collection.all, + .consoles => Collection.favorites, + } else switch (self.collection) { + .all => Collection.favorites, + .favorites => if (has_consoles) Collection.consoles else Collection.all, + .consoles => Collection.all, + }; self.rebuild(store, preserve); } @@ -149,7 +178,11 @@ pub fn draw( style.drawMark(renderer); font.text(renderer, 78, 12, 4, "GREENOVERCAST", style.bright()); - drawTabs(renderer, view.collection); + drawTabs(renderer, view.collection, view.consoles.len > 0); + if (view.collection == .consoles) { + drawConsoles(renderer, view, store); + return; + } if (view.count == 0) { const empty = if (view.collection == .favorites) "NO FAVORITES YET" else "NO MATCHING GAMES"; font.text(renderer, 28, 206, 3, empty, style.muted()); @@ -221,12 +254,14 @@ pub fn draw( c.SDL_RenderPresent(renderer); } -fn drawTabs(renderer: *c.SDL_Renderer, active: Collection) void { +fn drawTabs(renderer: *c.SDL_Renderer, active: Collection, has_consoles: bool) void { const labels = [_]struct { Collection, [*:0]const u8, c_int }{ .{ .all, "ALL", 210 }, .{ .favorites, "FAVORITES", 300 }, + .{ .consoles, "CONSOLES", 430 }, }; for (labels) |entry| { + if (entry[0] == .consoles and !has_consoles) continue; if (entry[0] == active) { style.setColor(renderer, style.selection()); var rect = c.SDL_Rect{ .x = entry[2] - 10, .y = 48, .w = font.textWidth(entry[1], 2) + 20, .h = 24 }; @@ -236,6 +271,54 @@ fn drawTabs(renderer: *c.SDL_Renderer, active: Collection) void { } } +// xHome tab: one row per console (name + power state). Selecting a row and +// pressing A starts a home stream to that console. +fn drawConsoles(renderer: *c.SDL_Renderer, view: *const View, store: *const settings.Store) void { + const visible_rows: usize = 9; + var start = view.console_selected -| visible_rows / 2; + start = @min(start, view.consoles.len -| visible_rows); + var row: usize = 0; + while (row < visible_rows) : (row += 1) { + const index = start + row; + if (index >= view.consoles.len) break; + const console = &view.consoles[index]; + const y: c_int = @intCast(86 + row * 36); + const selected = index == view.console_selected; + if (selected) { + style.setColor(renderer, style.selection()); + var highlight = c.SDL_Rect{ .x = 14, .y = y, .w = 612, .h = 32 }; + _ = c.SDL_RenderFillRect(renderer, &highlight); + style.setColor(renderer, style.accent()); + var bar = c.SDL_Rect{ .x = 14, .y = y, .w = 5, .h = 32 }; + _ = c.SDL_RenderFillRect(renderer, &bar); + } + const state_text: [*c]const u8 = if (console.power_state[0] != 0) @ptrCast(&console.power_state) else "UNKNOWN"; + const awake = std.ascii.eqlIgnoreCase(bufferString(&console.power_state), "On"); + const state_width = font.textWidth(state_text, 2); + font.textEllipsized( + renderer, + 30, + y + 5, + 3, + @ptrCast(&console.name), + 612 - state_width - 60, + if (selected) style.bright() else style.muted(), + ); + font.text(renderer, 626 - state_width - 8, y + 9, 2, state_text, if (awake) style.accent() else style.warning()); + } + const primary = [_]controls.Prompt{ + controls.Prompt.one(controls.face(store.face_buttons, .a), "STREAM"), + controls.Prompt.one(controls.face(store.face_buttons, .b), "BACK"), + }; + const secondary = [_]controls.Prompt{ + controls.Prompt.two(.left_bumper, .right_bumper, "TAB"), + controls.Prompt.one(.start, "SETTINGS"), + }; + controls.drawRow(renderer, 16, 431, &primary, style.bright()); + controls.drawRow(renderer, 16, 455, &secondary, style.accent()); + c.SDL_RenderPresent(renderer); +} + fn drawArtwork(renderer: *c.SDL_Renderer, artwork: *c.SDL_Texture) void { var width: c_int = 0; var height: c_int = 0; From 5f4e9825d6eaa00806b7aa56077cff8af299ecd7 Mon Sep 17 00:00:00 2001 From: Domenico Laurito Date: Sat, 19 Sep 2026 17:08:07 +0200 Subject: [PATCH 03/13] perf(video): upload planar YUV directly and allow a second queued frame Software decoding produces YUV420P, which was converted to RGB24 on the CPU for every frame. Upload it to an IYUV texture instead, like NV12 already is, and fall back to the RGB path if the texture cannot be created. Also add an optional second display slot (go_video_pipeline_set_smooth) so two frames decoded within one render tick are both shown. Off by default. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjNU9aF1G5aFtThd3oqBZT --- src/media/video/video_pipeline.h | 3 + src/media/video/video_pipeline.zig | 92 +++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/src/media/video/video_pipeline.h b/src/media/video/video_pipeline.h index 9acf685..6747ceb 100644 --- a/src/media/video/video_pipeline.h +++ b/src/media/video/video_pipeline.h @@ -61,6 +61,9 @@ int go_video_pipeline_start(GoVideoPipeline* pipeline); void go_video_pipeline_stop(GoVideoPipeline* pipeline); void go_video_pipeline_push_rtp(GoVideoPipeline* pipeline, const uint8_t* packet, size_t length); void go_video_pipeline_render(GoVideoPipeline* pipeline); +/* Keep a second frame waiting so a burst of decoded frames is not dropped, at the + * cost of up to one frame of extra latency. */ +void go_video_pipeline_set_smooth(GoVideoPipeline* pipeline, int smooth); int go_video_pipeline_needs_keyframe(const GoVideoPipeline* pipeline); int go_video_pipeline_has_media(const GoVideoPipeline* pipeline); int go_video_pipeline_failed(const GoVideoPipeline* pipeline); diff --git a/src/media/video/video_pipeline.zig b/src/media/video/video_pipeline.zig index 9dded60..e2790ed 100644 --- a/src/media/video/video_pipeline.zig +++ b/src/media/video/video_pipeline.zig @@ -55,11 +55,15 @@ const Pipeline = struct { texture_width: c_int = 0, texture_height: c_int = 0, texture_format: c.Uint32 = c.SDL_PIXELFORMAT_UNKNOWN, - direct_nv12_available: bool = true, + direct_yuv_available: bool = true, upload_error_reported: bool = false, frame_mutex: std.Thread.Mutex = .{}, display_frame: ?*c.AVFrame = null, + // A second slot so two frames decoded within one render tick are both shown. + queued_frame: ?*c.AVFrame = null, + queued_valid: bool = false, + smooth: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), render_frame: ?*c.AVFrame = null, frame_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), @@ -211,6 +215,11 @@ fn publishFrame(pipeline: *Pipeline, frame: *c.AVFrame, decoder_name: [*c]const } pipeline.frame_mutex.lock(); defer pipeline.frame_mutex.unlock(); + if (pipeline.smooth.load(.monotonic) and pipeline.frame_ready.load(.acquire)) { + c.av_frame_unref(pipeline.queued_frame); + pipeline.queued_valid = c.av_frame_ref(pipeline.queued_frame, frame) == 0; + return; + } c.av_frame_unref(pipeline.display_frame); if (c.av_frame_ref(pipeline.display_frame, frame) == 0) pipeline.frame_ready.store(true, .release); @@ -425,23 +434,29 @@ fn configureRenderer(pipeline: *Pipeline, frame: *const c.AVFrame) c_int { const source_format = frame.format; const source_full_range: c_int = @intFromBool(frame.color_range == c.AVCOL_RANGE_JPEG); if (width <= 0 or height <= 0 or width > 8192 or height > 8192) return -1; - const direct_nv12 = source_format == c.AV_PIX_FMT_NV12 and pipeline.direct_nv12_available; - var requested_format: c.Uint32 = if (direct_nv12) c.SDL_PIXELFORMAT_NV12 else c.SDL_PIXELFORMAT_RGB24; + const direct_yuv = pipeline.direct_yuv_available and + (source_format == c.AV_PIX_FMT_NV12 or source_format == c.AV_PIX_FMT_YUV420P); + var requested_format: c.Uint32 = if (!direct_yuv) + c.SDL_PIXELFORMAT_RGB24 + else if (source_format == c.AV_PIX_FMT_NV12) + c.SDL_PIXELFORMAT_NV12 + else + c.SDL_PIXELFORMAT_IYUV; if (pipeline.texture != null and (width != pipeline.texture_width or height != pipeline.texture_height or requested_format != pipeline.texture_format)) destroyTexture(pipeline); if (pipeline.texture == null) { - if (direct_nv12) { + if (direct_yuv) { pipeline.texture = c.SDL_CreateTexture( pipeline.renderer, - c.SDL_PIXELFORMAT_NV12, + requested_format, c.SDL_TEXTUREACCESS_STREAMING, width, height, ); if (pipeline.texture == null) { - std.debug.print("Direct NV12 texture unavailable: {s}\n", .{std.mem.span(c.SDL_GetError())}); - pipeline.direct_nv12_available = false; + std.debug.print("Direct YUV texture unavailable: {s}\n", .{std.mem.span(c.SDL_GetError())}); + pipeline.direct_yuv_available = false; requested_format = c.SDL_PIXELFORMAT_RGB24; } } @@ -462,12 +477,17 @@ fn configureRenderer(pipeline: *Pipeline, frame: *const c.AVFrame) c_int { pipeline.texture_height = height; pipeline.texture_format = requested_format; if (debugEnabled()) std.debug.print("Created {s} texture {d}x{d}\n", .{ - if (requested_format == c.SDL_PIXELFORMAT_NV12) "NV12" else "RGB24", + if (requested_format == c.SDL_PIXELFORMAT_NV12) + "NV12" + else if (requested_format == c.SDL_PIXELFORMAT_IYUV) + "IYUV" + else + "RGB24", width, height, }); } - if (pipeline.texture_format == c.SDL_PIXELFORMAT_NV12) return 0; + if (pipeline.texture_format != c.SDL_PIXELFORMAT_RGB24) return 0; const scaler = c.sws_getCachedContext( pipeline.scaler, @@ -502,21 +522,34 @@ fn configureRenderer(pipeline: *Pipeline, frame: *const c.AVFrame) c_int { fn uploadFrame(pipeline: *Pipeline, frame: *const c.AVFrame) c_int { if (configureRenderer(pipeline, frame) < 0) return -1; - if (pipeline.texture_format == c.SDL_PIXELFORMAT_NV12) { + if (pipeline.texture_format != c.SDL_PIXELFORMAT_RGB24) { c.SDL_SetYUVConversionMode(if (frame.color_range == c.AVCOL_RANGE_JPEG) c.SDL_YUV_CONVERSION_JPEG else c.SDL_YUV_CONVERSION_BT709); - if (c.SDL_UpdateNVTexture( - pipeline.texture, - null, - frame.data[0], - frame.linesize[0], - frame.data[1], - frame.linesize[1], - ) == 0) return 0; - std.debug.print("Direct NV12 upload disabled: {s}\n", .{std.mem.span(c.SDL_GetError())}); - pipeline.direct_nv12_available = false; + const uploaded = if (pipeline.texture_format == c.SDL_PIXELFORMAT_NV12) + c.SDL_UpdateNVTexture( + pipeline.texture, + null, + frame.data[0], + frame.linesize[0], + frame.data[1], + frame.linesize[1], + ) + else + c.SDL_UpdateYUVTexture( + pipeline.texture, + null, + frame.data[0], + frame.linesize[0], + frame.data[1], + frame.linesize[1], + frame.data[2], + frame.linesize[2], + ); + if (uploaded == 0) return 0; + std.debug.print("Direct YUV upload disabled: {s}\n", .{std.mem.span(c.SDL_GetError())}); + pipeline.direct_yuv_available = false; destroyTexture(pipeline); if (configureRenderer(pipeline, frame) < 0) return -1; } @@ -562,8 +595,10 @@ pub export fn go_video_pipeline_create(config_pointer: ?*const c.GoVideoPipeline if (pipeline.depacketizer == null or loadBootstrap(pipeline) != 0) return null; pipeline.decoded_frame = c.av_frame_alloc(); pipeline.display_frame = c.av_frame_alloc(); + pipeline.queued_frame = c.av_frame_alloc(); pipeline.render_frame = c.av_frame_alloc(); - if (pipeline.decoded_frame == null or pipeline.display_frame == null or pipeline.render_frame == null) + if (pipeline.decoded_frame == null or pipeline.display_frame == null or + pipeline.queued_frame == null or pipeline.render_frame == null) return null; if (selectDecoder(pipeline, config.decoder_preference) != 0) return null; return pipeline; @@ -629,6 +664,11 @@ pub export fn go_video_pipeline_push_rtp( pipeline.packet_condition.signal(); } +pub export fn go_video_pipeline_set_smooth(pipeline_pointer: ?*Pipeline, smooth: c_int) void { + const pipeline = pipeline_pointer orelse return; + pipeline.smooth.store(smooth != 0, .monotonic); +} + pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void { const pipeline = pipeline_pointer orelse return; if (!pipeline.frame_ready.load(.acquire)) return; @@ -636,7 +676,12 @@ pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void { if (pipeline.frame_ready.load(.acquire)) { c.av_frame_unref(pipeline.render_frame); c.av_frame_move_ref(pipeline.render_frame, pipeline.display_frame); - pipeline.frame_ready.store(false, .release); + if (pipeline.queued_valid) { + c.av_frame_move_ref(pipeline.display_frame, pipeline.queued_frame); + pipeline.queued_valid = false; + } else { + pipeline.frame_ready.store(false, .release); + } } pipeline.frame_mutex.unlock(); const frame = pipeline.render_frame orelse return; @@ -735,9 +780,12 @@ pub export fn go_video_pipeline_destroy(pipeline_pointer: ?*Pipeline) c_int { pipeline.frame_mutex.lock(); pipeline.frame_ready.store(false, .release); if (pipeline.display_frame) |frame| c.av_frame_unref(frame); + if (pipeline.queued_frame) |frame| c.av_frame_unref(frame); + pipeline.queued_valid = false; pipeline.frame_mutex.unlock(); freeFrame(&pipeline.render_frame); freeFrame(&pipeline.display_frame); + freeFrame(&pipeline.queued_frame); if (pipeline.scaler) |scaler| c.sws_freeContext(scaler); c.free(pipeline.rgb_buffer); destroyTexture(pipeline); From 080922e001db34b6a1797eb812cb9fa30c02d714 Mon Sep 17 00:00:00 2001 From: Domenico Laurito Date: Sat, 19 Sep 2026 17:08:07 +0200 Subject: [PATCH 04/13] feat(settings): video decoder and smooth video options The Cedar hardware decoder can leave blocky artifacts on the right side of the picture on some streams (cloud and console alike); the software decoder does not. Add a Settings row to choose Auto or Software decoding, applied to the next stream, and one to enable the second queued display frame. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjNU9aF1G5aFtThd3oqBZT --- README.md | 11 +++++++++-- src/app/release.zig | 18 ++++++++++++++++++ src/ui/handheld_ui.h | 4 ++++ src/ui/handheld_ui.zig | 8 ++++++++ src/ui/persistent_settings.zig | 24 +++++++++++++++++++++++- src/ui/settings_view.zig | 20 ++++++++++++++++---- 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d2a7b68..a444626 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,15 @@ GreenOvercast is experimental. | L3 + R3 | — | Xbox Guide | | 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. +Settings include Xbox/Nintendo face-button layouts, game artwork, the video +decoder, video smoothing, and Sign out. Games that return a 16:9 stream remain +letterboxed on 4:3 displays. + +The **video decoder** can be switched from Auto to Software. On some streams +the Cedar hardware decoder leaves blocky artifacts on the right side of the +picture; software decoding avoids them at the cost of CPU time. **Smooth video** +keeps a second frame queued so bursts of decoded frames are not dropped, at the +cost of up to one frame of extra latency. ## Home streaming diff --git a/src/app/release.zig b/src/app/release.zig index 3acbfc3..c8b475e 100644 --- a/src/app/release.zig +++ b/src/app/release.zig @@ -73,6 +73,7 @@ pub const Release = struct { home_needs_connect: bool = false, home_provisioned: bool = false, webrtc: ?*c.GoWebrtcSession = null, + software_decoder: bool = false, catalog: ?*catalog_service.Service = null, curl_initialized: bool = false, requested_title: [128]u8 = [_]u8{0} ** 128, @@ -89,6 +90,9 @@ pub const Release = struct { std.debug.print("Invalid GREENOVERCAST_VIDEO_DECODER value\n", .{}); return false; } + if (decoder_value == null and c.go_handheld_ui_software_decoder(self.ui()) != 0) + decoder_preference = c.GO_VIDEO_DECODER_PREFERENCE_SOFTWARE; + self.software_decoder = decoder_preference == c.GO_VIDEO_DECODER_PREFERENCE_SOFTWARE; const config = c.GoVideoPipelineConfig{ .renderer = c.go_sdl_platform_renderer(self.platform), .bootstrap_path = if (bootstrap_path) |path| path.ptr else null, @@ -114,6 +118,18 @@ pub const Release = struct { return true; } + // Settings can switch the video decoder between streams; rebuild the media + // pipeline when the wanted decoder no longer matches the running one. + fn refreshDecoder(self: *Release) bool { + const wanted = if (std.posix.getenv("GREENOVERCAST_VIDEO_DECODER")) |value| + std.mem.eql(u8, value, "software") + else + c.go_handheld_ui_software_decoder(self.ui()) != 0; + if (wanted == self.software_decoder) return true; + self.destroyMedia(); + return self.initializeMedia(); + } + fn destroyMedia(self: *Release) void { c.go_audio_pipeline_destroy(self.audio); self.audio = null; @@ -329,6 +345,7 @@ pub const Release = struct { } pub fn createSession(self: *Release) Result { + if (!self.refreshDecoder()) return .failed; if (self.mode == .home) { if (self.server_id[0] == 0 or self.home == null) return .failed; self.drawLoading("STARTING STREAM", "CONNECTING TO YOUR XBOX", c.GO_HANDHELD_UI_ACTION_CANCEL); @@ -474,6 +491,7 @@ pub const Release = struct { } const controller_input = self.controller(); + c.go_video_pipeline_set_smooth(self.video, c.go_handheld_ui_smooth_video(self.ui())); while (true) { var event: c.SDL_Event = undefined; while (c.SDL_PollEvent(&event) != 0) { diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h index f74f366..e535fee 100644 --- a/src/ui/handheld_ui.h +++ b/src/ui/handheld_ui.h @@ -53,6 +53,10 @@ int go_handheld_ui_pick_title(GoHandheldUi* ui, const GoCatalogTitle* titles, in * GO_UI_MAX_CONSOLES). count <= 0 hides the tab. */ void go_handheld_ui_set_consoles(GoHandheldUi* ui, const GoUiConsoleRow* rows, int count); int go_handheld_ui_cancelled(const GoHandheldUi* ui); +/* Nonzero when Settings asks for the software video decoder. */ +int go_handheld_ui_software_decoder(const GoHandheldUi* ui); +/* Nonzero when Settings asks for frame smoothing. */ +int go_handheld_ui_smooth_video(const GoHandheldUi* ui); unsigned int go_handheld_ui_stream_width(const GoHandheldUi* ui); unsigned int go_handheld_ui_stream_height(const GoHandheldUi* ui); diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index f57da9c..1b25fb9 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -683,6 +683,14 @@ pub export fn go_handheld_ui_cancelled(ui: ?*const Ui) c_int { return @intFromBool(if (ui) |handle| handle.cancelled else false); } +pub export fn go_handheld_ui_software_decoder(ui: ?*const Ui) c_int { + return @intFromBool((ui orelse return 0).settings.video_decoder == .software); +} + +pub export fn go_handheld_ui_smooth_video(ui: ?*const Ui) c_int { + return @intFromBool((ui orelse return 0).settings.smooth_video); +} + pub export fn go_handheld_ui_stream_width(ui: ?*const Ui) c_uint { return (ui orelse return 640).stream_width; } diff --git a/src/ui/persistent_settings.zig b/src/ui/persistent_settings.zig index 32eef01..2c2c8b5 100644 --- a/src/ui/persistent_settings.zig +++ b/src/ui/persistent_settings.zig @@ -8,6 +8,13 @@ pub const FaceButtonMode = enum { swapped, }; +// The Cedar hardware decoder can leave blocky artifacts on some streams; +// software decoding avoids them at the cost of CPU time. +pub const VideoDecoder = enum { + auto, + software, +}; + pub const GameSettings = struct { product_id: [product_id_capacity]u8 = [_]u8{0} ** product_id_capacity, favorite: bool = false, @@ -18,6 +25,8 @@ pub const Store = struct { path_length: usize = 0, face_buttons: FaceButtonMode = .system, artwork_enabled: bool = true, + video_decoder: VideoDecoder = .auto, + smooth_video: bool = false, games: [max_games]GameSettings = [_]GameSettings{.{}} ** max_games, game_count: usize = 0, @@ -70,6 +79,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("video_decoder\t{s}\n", .{@tagName(self.video_decoder)}); + try writer.print("smooth_video\t{d}\n", .{@intFromBool(self.smooth_video)}); for (self.games[0..self.game_count]) |*entry| { try writer.print("game\t{s}\t{d}\n", .{ productId(entry), @@ -118,6 +129,13 @@ 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, "video_decoder")) { + const value = fields.next() orelse continue; + if (std.mem.eql(u8, value, "auto")) self.video_decoder = .auto; + if (std.mem.eql(u8, value, "software")) self.video_decoder = .software; + } else if (std.mem.eql(u8, kind, "smooth_video")) { + const value = fields.next() orelse continue; + self.smooth_video = std.mem.eql(u8, value, "1"); } else if (std.mem.eql(u8, kind, "game")) { const id = fields.next() orelse continue; const favorite = fields.next() orelse continue; @@ -145,18 +163,22 @@ test "settings round trip through the file format" { var store = Store{}; store.face_buttons = .swapped; store.artwork_enabled = false; + store.video_decoder = .software; + store.smooth_video = true; 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("version\t1\nface_buttons\tswapped\nartwork\t0\nvideo_decoder\tsoftware\nsmooth_video\t1\n"); try data.writer().writeAll("game\tPRODUCT-1\t1\n"); 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.expectEqual(VideoDecoder.software, parsed.video_decoder); + try std.testing.expect(parsed.smooth_video); const parsed_game = parsed.findGame("PRODUCT-1").?; try std.testing.expect(parsed_game.favorite); } diff --git a/src/ui/settings_view.zig b/src/ui/settings_view.zig index c669f67..b2fb4b0 100644 --- a/src/ui/settings_view.zig +++ b/src/ui/settings_view.zig @@ -21,6 +21,8 @@ const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int; const Row = enum { face_buttons, artwork, + video_decoder, + smooth_video, sign_out, }; @@ -117,6 +119,8 @@ fn activate( ); }, .artwork => store.artwork_enabled = !store.artwork_enabled, + .video_decoder => store.video_decoder = if (store.video_decoder == .auto) .software else .auto, + .smooth_video => store.smooth_video = !store.smooth_video, .sign_out => return true, } store.save() catch std.debug.print("Settings could not be saved\n", .{}); @@ -127,14 +131,18 @@ fn previousRow(row: Row) Row { return switch (row) { .face_buttons => .sign_out, .artwork => .face_buttons, - .sign_out => .artwork, + .video_decoder => .artwork, + .smooth_video => .video_decoder, + .sign_out => .smooth_video, }; } fn nextRow(row: Row) Row { return switch (row) { .face_buttons => .artwork, - .artwork => .sign_out, + .artwork => .video_decoder, + .video_decoder => .smooth_video, + .smooth_video => .sign_out, .sign_out => .face_buttons, }; } @@ -165,10 +173,14 @@ fn draw( drawRow(renderer, 92, "FACE BUTTONS", if (store.face_buttons == .system) "SYSTEM" else "SWAPPED", selected == .face_buttons); drawRow(renderer, 148, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork); - drawRow(renderer, 204, "ACCOUNT", "SIGN OUT", selected == .sign_out); + drawRow(renderer, 204, "VIDEO DECODER", if (store.video_decoder == .auto) "AUTO" else "SOFTWARE", selected == .video_decoder); + drawRow(renderer, 260, "SMOOTH VIDEO", if (store.smooth_video) "ON" else "OFF", selected == .smooth_video); + drawRow(renderer, 316, "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, 362, 2, "USE SWAPPED ONLY IF BUTTONS ARE REVERSED", style.muted()); + font.text(renderer, 18, 380, 2, "SOFTWARE DECODER: NO BLOCKY VIDEO, MORE CPU", style.muted()); + font.text(renderer, 18, 398, 2, "SMOOTH VIDEO: FEWER SKIPPED FRAMES, MORE DELAY", style.muted()); const prompts = [_]controls.Prompt{ controls.Prompt.one(controls.face(store.face_buttons, .a), "CHANGE"), controls.Prompt.one(.dpad, "MOVE"), From a10b1b7456e0792264d0371dbf99192074bd4537 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:02:46 +0000 Subject: [PATCH 05/13] net: allow a request-specific HTTP timeout go_http_request always used a hardwired 30s CURLOPT_TIMEOUT. Best-effort requests (xHome discovery) need a much shorter deadline of their own so an unreachable optional service can never hold up a real, time-sensitive flow. Adds go_http_request_with_timeout on top of the same internal request path, with the existing entry points unchanged (still default to 30s). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/net/http_client.c | 26 +++++++++++++++++++++----- src/net/http_client.h | 6 ++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/net/http_client.c b/src/net/http_client.c index f0b51f0..2af2ae1 100644 --- a/src/net/http_client.c +++ b/src/net/http_client.c @@ -10,6 +10,7 @@ #include "../util/log.h" #define DEFAULT_RESPONSE_LIMIT (16 * 1024 * 1024) +#define DEFAULT_TIMEOUT_SECONDS 30L typedef struct { GoHttpResponse* response; @@ -90,12 +91,12 @@ int go_http_response_succeeded(const GoHttpResponse* response) { return response && response->status >= 200 && response->status < 300; } -GoHttpResponse* go_http_request_bounded_cancelable( +static GoHttpResponse* http_request( const char* method, const char* url, const char* body, const char** headers, - int header_count, size_t response_limit, GoHttpCancelRequested cancel_requested, - void* cancel_context) { + int header_count, size_t response_limit, long timeout_seconds, + GoHttpCancelRequested cancel_requested, void* cancel_context) { if (!method || !url || response_limit == 0 || header_count < 0 || - (header_count > 0 && !headers)) + (header_count > 0 && !headers) || timeout_seconds <= 0) return NULL; CURL* request = curl_easy_init(); @@ -133,7 +134,7 @@ GoHttpResponse* go_http_request_bounded_cancelable( ResponseWriter writer = {.response = response, .limit = response_limit}; curl_easy_setopt(request, CURLOPT_WRITEFUNCTION, append_response); curl_easy_setopt(request, CURLOPT_WRITEDATA, &writer); - curl_easy_setopt(request, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(request, CURLOPT_TIMEOUT, timeout_seconds); curl_easy_setopt(request, CURLOPT_NOSIGNAL, 1L); const char* ca_bundle = find_ca_bundle(); if (ca_bundle) @@ -161,6 +162,14 @@ GoHttpResponse* go_http_request_bounded_cancelable( return response; } +GoHttpResponse* go_http_request_bounded_cancelable( + const char* method, const char* url, const char* body, const char** headers, + int header_count, size_t response_limit, GoHttpCancelRequested cancel_requested, + void* cancel_context) { + return http_request(method, url, body, headers, header_count, response_limit, + DEFAULT_TIMEOUT_SECONDS, cancel_requested, cancel_context); +} + GoHttpResponse* go_http_request_bounded(const char* method, const char* url, const char* body, const char** headers, int header_count, size_t response_limit) { @@ -173,3 +182,10 @@ GoHttpResponse* go_http_request(const char* method, const char* url, const char* return go_http_request_bounded(method, url, body, headers, header_count, DEFAULT_RESPONSE_LIMIT); } + +GoHttpResponse* go_http_request_with_timeout(const char* method, const char* url, const char* body, + const char** headers, int header_count, + long timeout_seconds) { + return http_request(method, url, body, headers, header_count, DEFAULT_RESPONSE_LIMIT, + timeout_seconds, NULL, NULL); +} diff --git a/src/net/http_client.h b/src/net/http_client.h index 1d23585..51f38b8 100644 --- a/src/net/http_client.h +++ b/src/net/http_client.h @@ -24,6 +24,12 @@ GoHttpResponse* go_http_request_bounded_cancelable( const char* method, const char* url, const char* body, const char** headers, int header_count, size_t response_limit, GoHttpCancelRequested cancel_requested, void* cancel_context); +/* Same as go_http_request, but with an explicit timeout instead of the + * default 30s. Intended for best-effort/optional requests (e.g. xHome + * discovery) that must never hold up an unrelated, time-sensitive flow. */ +GoHttpResponse* go_http_request_with_timeout(const char* method, const char* url, const char* body, + const char** headers, int header_count, + long timeout_seconds); int go_http_response_succeeded(const GoHttpResponse* response); void go_http_response_destroy(GoHttpResponse* response); From c2d5b8c2d6bac785014fc77f873c9ca67f07a5b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:02:51 +0000 Subject: [PATCH 06/13] auth,catalog: give xHome discovery a short timeout The xHome offering request in refresh() and the console-list fetch in fetchConsolesJson() are both best-effort lookups that ran on the default 30s HTTP timeout. An unreachable or slow xHome service could hold cloud sign-in for the full 30s before falling back to cloud-only. Both now use go_http_request_with_timeout with a 5s deadline instead. Also corrects fetchConsolesJson's comment: go_cloud_session_request is already offering-aware (this PR made it switch between the cloud and home token/base URL), so the duplicated request-building code here is no longer justified by that being unsupported - it stays separate because it needs its own short timeout, which go_cloud_session_request doesn't provide. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/auth/xbox_auth.zig | 7 ++++++- src/catalog/consoles.zig | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/auth/xbox_auth.zig b/src/auth/xbox_auth.zig index 691335c..c02af66 100644 --- a/src/auth/xbox_auth.zig +++ b/src/auth/xbox_auth.zig @@ -16,6 +16,10 @@ const passport_scope = "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD const offering_url = "https://xgpuweb.gssv-play-prod.xboxlive.com/v2/login/user"; const home_offering_url = "https://xhome.gssv-play-prod.xboxlive.com/v2/login/user"; const xbox_web_client_id = "1f907974-e22b-4810-a9de-d9647380c97e"; +// xHome discovery is best-effort and optional: an account with no linked +// console must never make cloud sign-in wait out the full 30s HTTP timeout, +// so it gets a much shorter deadline of its own. +const home_offering_timeout_seconds: c_long = 5; const auth_failed: c_int = -1; const auth_ok: c_int = 0; @@ -423,12 +427,13 @@ fn refresh(auth: *Auth) !c_int { "{{\"token\":\"{s}\",\"offeringId\":\"xhome\"}}", .{xsts}, ); - response = c.go_http_request( + response = c.go_http_request_with_timeout( "POST", home_offering_url, home_offering_body.ptr, @ptrCast(&gssv_headers), gssv_headers.len, + home_offering_timeout_seconds, ); if (response != null and response.*.status == 200) { if (responseData(response)) |home_data| { diff --git a/src/catalog/consoles.zig b/src/catalog/consoles.zig index 2f44080..99f698a 100644 --- a/src/catalog/consoles.zig +++ b/src/catalog/consoles.zig @@ -32,10 +32,19 @@ fn debug(comptime format: []const u8, args: anytype) void { if (debugEnabled()) std.debug.print(format, args); } +// This is a best-effort lookup (no consoles just hides the CONSOLES tab), so +// it must never delay the return to the cloud UI by the full 30s HTTP +// timeout if the xHome service is unreachable or slow. +const fetch_timeout_seconds: c_long = 5; + // Mirrors cloud_session.zig's private request() (same headers, same // X-MS-Device-Info shape) but against the home base URL/token discovered by -// xbox_auth's refresh(), since go_cloud_session_request is hardwired to the -// cloud token and must stay that way. +// xbox_auth's refresh(). go_cloud_session_request is offering-aware (it +// already switches between the cloud and home token/base URL), but this +// lookup is built directly on top of the HTTP client instead of going +// through it: it is best-effort and needs its own short timeout +// (fetch_timeout_seconds) so an unreachable xHome service never holds up +// the cloud sign-in flow, which go_cloud_session_request does not support. fn fetchConsolesJson( auth: *c.GoXboxAuth, ui: ?*c.GoHandheldUi, @@ -73,7 +82,14 @@ fn fetchConsolesJson( "Accept: application/json", device_info_header.ptr, }; - return c.go_http_request("GET", url.ptr, null, @ptrCast(&headers), headers.len); + return c.go_http_request_with_timeout( + "GET", + url.ptr, + null, + @ptrCast(&headers), + headers.len, + fetch_timeout_seconds, + ); } fn fetchConsoles(auth: *c.GoXboxAuth, ui: ?*c.GoHandheldUi, consoles: []parser.Console) !usize { From 86cd9ce3baa6ffd7f80c252b8150994ac727ee5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:02:57 +0000 Subject: [PATCH 07/13] video: don't drop a queued frame during decode bursts In smooth-video mode, publishFrame queued a second decoded frame ahead of display_frame while the display slot was still waiting to be rendered. If a third frame arrived before the render loop consumed the queued one, it unconditionally overwrote queued_frame, silently dropping the frame that was already queued. Now it only fills the queue slot when it's empty, dropping the newer frame instead so nothing already queued is lost. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/media/video/video_pipeline.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/media/video/video_pipeline.zig b/src/media/video/video_pipeline.zig index e2790ed..af4fe9d 100644 --- a/src/media/video/video_pipeline.zig +++ b/src/media/video/video_pipeline.zig @@ -216,8 +216,11 @@ fn publishFrame(pipeline: *Pipeline, frame: *c.AVFrame, decoder_name: [*c]const pipeline.frame_mutex.lock(); defer pipeline.frame_mutex.unlock(); if (pipeline.smooth.load(.monotonic) and pipeline.frame_ready.load(.acquire)) { - c.av_frame_unref(pipeline.queued_frame); - pipeline.queued_valid = c.av_frame_ref(pipeline.queued_frame, frame) == 0; + // Only one frame can be queued ahead of display_frame; if it's still + // waiting to be rendered, drop this newer frame instead of + // overwriting (and losing) the one already queued. + if (!pipeline.queued_valid) + pipeline.queued_valid = c.av_frame_ref(pipeline.queued_frame, frame) == 0; return; } c.av_frame_unref(pipeline.display_frame); From cab117327469b0474401afadc6afce6301113379 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:03:02 +0000 Subject: [PATCH 08/13] ui: NUL-terminate console name/power state after copy go_handheld_ui_set_consoles copies fixed-size C strings into console_rows with a plain @memcpy. A source string that exactly fills its buffer leaves no room for a terminator, and downstream rendering reads these fields with std.mem.span, which would then scan past the end of the buffer looking for one. Force a trailing NUL on both fields after the copy. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/ui/handheld_ui.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 1b25fb9..9687e45 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -674,7 +674,9 @@ pub export fn go_handheld_ui_set_consoles(ui: ?*Ui, rows: [*c]const c.GoUiConsol const limit: usize = @min(@as(usize, @intCast(count)), c.GO_UI_MAX_CONSOLES); for (0..limit) |index| { @memcpy(&handle.console_rows[index].name, &rows[index].name); + handle.console_rows[index].name[handle.console_rows[index].name.len - 1] = 0; @memcpy(&handle.console_rows[index].power_state, &rows[index].power_state); + handle.console_rows[index].power_state[handle.console_rows[index].power_state.len - 1] = 0; } handle.console_count = limit; } From 4c05a06309f650f5312dad9e0a93d868d70b3214 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:03:07 +0000 Subject: [PATCH 09/13] app: refresh console list on return to library; fix session-end message loadConsoles() only ran inside refreshAuth on a successful cloud sign-in, so a console that went to sleep during or after a home stream kept showing "On" in the CONSOLES tab until the next full sign-in. resetSession now re-runs it (best effort, same as at sign-in) when returning to the library. Also, the streaming loop always logged "Cloud game ended" when the WebRTC session closed, even during a home (xHome) stream. The message now reflects self.mode. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/app/release.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/release.zig b/src/app/release.zig index c8b475e..c61da7a 100644 --- a/src/app/release.zig +++ b/src/app/release.zig @@ -507,7 +507,7 @@ pub const Release = struct { return .failed; } if (c.go_webrtc_session_closed(self.webrtc) != 0) { - std.debug.print("Cloud game ended\n", .{}); + std.debug.print("{s}\n", .{if (self.mode == .home) "Console game ended" else "Cloud game ended"}); return .session_ended; } if (c.go_video_pipeline_failed(self.video) != 0) @@ -623,6 +623,10 @@ pub const Release = struct { self.mode = .cloud; if (stopRequested()) return .cancelled; if (!self.initializeMedia()) return .failed; + // Best effort: refreshes power state (e.g. a console that went to + // sleep during/after streaming) so the CONSOLES tab doesn't keep + // showing stale state until the next sign-in. + self.loadConsoles(); return .ok; } From 0bb9f36aa922a981d840f7ea0fd14bb505d93fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:03:13 +0000 Subject: [PATCH 10/13] ui: preserve selected title across the CONSOLES tab switchCollection dropped the previously selected title when leaving the CONSOLES tab (it has no title list of its own, so selectedTitleIndex() read back null) and always landed back on the first title in ALL/ FAVORITES. It now remembers the title selected on the way in and restores it on the way out, matching how ALL <-> FAVORITES already preserves selection. Adds unit tests for this and wires library_view.zig into the host test suite (it wasn't covered before), including the module import and project include paths its C imports need. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- build.zig | 46 +++++++++++++++++++++-------------- src/ui/library_view.zig | 54 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/build.zig b/build.zig index b1e6c0a..57f5b25 100644 --- a/build.zig +++ b/build.zig @@ -458,30 +458,40 @@ pub fn build(b: *std.Build) void { rocknix_build_regression_test.setCwd(b.path(".")); test_step.dependOn(&rocknix_build_regression_test.step); - const test_roots = [_][]const u8{ - "src/app/state.zig", - "src/catalog/catalog_parser.zig", - "src/catalog/catalog_search.zig", - "src/catalog/consoles_parser.zig", - "src/input/wire_encoder.zig", - "src/input/guide_chord.zig", - "src/session/message_protocol.zig", - "src/ui/keyboard.zig", - "src/ui/control_icons.zig", - "src/ui/navigation_repeat.zig", - "src/ui/persistent_settings.zig", - "src/ui/stream_dimensions.zig", - "src/media/rtp/h264_depacketizer.zig", - "src/net/json_reader.zig", - "src/net/json_writer.zig", - "src/net/form_writer.zig", + const test_roots = [_]struct { + path: []const u8, + imports: []const ZigImport = &.{}, + }{ + .{ .path = "src/app/state.zig" }, + .{ .path = "src/catalog/catalog_parser.zig" }, + .{ .path = "src/catalog/catalog_search.zig" }, + .{ .path = "src/catalog/consoles_parser.zig" }, + .{ .path = "src/input/wire_encoder.zig" }, + .{ .path = "src/input/guide_chord.zig" }, + .{ .path = "src/session/message_protocol.zig" }, + .{ .path = "src/ui/keyboard.zig" }, + .{ .path = "src/ui/control_icons.zig" }, + .{ .path = "src/ui/navigation_repeat.zig" }, + .{ + .path = "src/ui/library_view.zig", + .imports = &.{.{ .name = "catalog_search", .path = "src/catalog/catalog_search.zig" }}, + }, + .{ .path = "src/ui/persistent_settings.zig" }, + .{ .path = "src/ui/stream_dimensions.zig" }, + .{ .path = "src/media/rtp/h264_depacketizer.zig" }, + .{ .path = "src/net/json_reader.zig" }, + .{ .path = "src/net/json_writer.zig" }, + .{ .path = "src/net/form_writer.zig" }, }; for (test_roots) |root| { const unit_tests = b.addTest(.{ - .root_source_file = b.path(root), + .root_source_file = b.path(root.path), .target = b.graph.host, .optimize = .Debug, }); + addProjectIncludes(b, unit_tests.root_module); + for (root.imports) |item| + addZigImport(b, unit_tests.root_module, b.graph.host, .Debug, item.name, item.path); test_step.dependOn(&b.addRunArtifact(unit_tests).step); } diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig index a965c02..3407c7e 100644 --- a/src/ui/library_view.zig +++ b/src/ui/library_view.zig @@ -37,6 +37,9 @@ pub const View = struct { query: [query_capacity]u8 = [_]u8{0} ** query_capacity, consoles: []const ConsoleRow = &.{}, console_selected: usize = 0, + // Title selected just before entering the CONSOLES tab (which has no + // title list of its own), restored when leaving it. + saved_title_index: ?usize = null, pub fn rebuild(self: *View, store: *const settings.Store, preserve_title_index: ?usize) void { self.count = 0; @@ -81,7 +84,9 @@ pub const View = struct { } pub fn switchCollection(self: *View, store: *const settings.Store, direction: i8) void { - const preserve = self.selectedTitleIndex(); + // Leaving CONSOLES: the title list is empty (see rebuild), so recover + // the selection saved on the way in instead of losing it. + const preserve = if (self.collection == .consoles) self.saved_title_index else self.selectedTitleIndex(); // Tab order: ALL -> FAVORITES -> CONSOLES (only when consoles exist) -> ALL. const has_consoles = self.consoles.len > 0; self.collection = if (direction < 0) switch (self.collection) { @@ -93,6 +98,7 @@ pub const View = struct { .favorites => if (has_consoles) Collection.consoles else Collection.all, .consoles => Collection.all, }; + if (self.collection == .consoles) self.saved_title_index = preserve; self.rebuild(store, preserve); } @@ -379,3 +385,49 @@ fn titleInitial(title: *const Title) u8 { fn bufferString(buffer: []const u8) []const u8 { return buffer[0 .. std.mem.indexOfScalar(u8, buffer, 0) orelse buffer.len]; } + +fn testTitle(name: []const u8) Title { + var title = std.mem.zeroes(Title); + @memcpy(title.name[0..name.len], name); + @memcpy(title.product_id[0..name.len], name); + return title; +} + +test "switchCollection preserves the selected title across the CONSOLES tab" { + const titles = [_]Title{ testTitle("Alpha"), testTitle("Bravo"), testTitle("Charlie") }; + var indices: [titles.len]usize = undefined; + const consoles = [_]ConsoleRow{std.mem.zeroes(ConsoleRow)}; + var store = try settings.Store.init(null); + var view = View{ .titles = &titles, .indices = &indices, .consoles = &consoles }; + view.rebuild(&store, null); + view.selected = 1; + const expected = view.selectedTitleIndex().?; + try std.testing.expectEqual(@as(usize, 1), expected); + + // .all -> .consoles directly (has_consoles is true). + view.switchCollection(&store, -1); + try std.testing.expectEqual(Collection.consoles, view.collection); + try std.testing.expectEqual(@as(usize, 0), view.count); + + // .consoles -> .all: the title selected before entering CONSOLES comes back. + view.switchCollection(&store, 1); + try std.testing.expectEqual(Collection.all, view.collection); + try std.testing.expectEqual(expected, view.selectedTitleIndex().?); +} + +test "switchCollection without visiting CONSOLES keeps prior behavior" { + const titles = [_]Title{ testTitle("Alpha"), testTitle("Bravo") }; + var indices: [titles.len]usize = undefined; + var store = try settings.Store.init(null); + (store.game("Bravo") orelse unreachable).favorite = true; + var view = View{ .titles = &titles, .indices = &indices }; + view.rebuild(&store, null); + view.selected = 1; + const expected = view.selectedTitleIndex().?; + + view.switchCollection(&store, 1); // .all -> .favorites (no consoles) + try std.testing.expectEqual(Collection.favorites, view.collection); + view.switchCollection(&store, -1); // .favorites -> .all + try std.testing.expectEqual(Collection.all, view.collection); + try std.testing.expectEqual(expected, view.selectedTitleIndex().?); +} From 8245c83f987194596855dfc6488482dd0ff1a235 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:22:03 +0000 Subject: [PATCH 11/13] catalog,ui: allow an empty cloud catalog when consoles are available Service.load and Service.pick both failed with error.EmptyCatalog whenever the cloud title list had zero entries, even when the account has xHome consoles to stream from instead - blocking the whole app (loadCatalog treats EmptyCatalog as fatal) before the user could ever reach the CONSOLES tab. Same assumption in go_handheld_ui_pick_title/pickTitle: a zero-count title list was cancelled outright regardless of console rows. Adds go_handheld_ui_console_count so Service can check for that case, and only fails with EmptyCatalog when there are neither titles nor consoles. The picker now runs with an empty title slice when count is 0, so the CONSOLES tab is still reachable. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/catalog/service.zig | 9 +++++++-- src/ui/handheld_ui.h | 2 ++ src/ui/handheld_ui.zig | 21 +++++++++++++++++---- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/catalog/service.zig b/src/catalog/service.zig index 7f3bf20..0e67e6c 100644 --- a/src/catalog/service.zig +++ b/src/catalog/service.zig @@ -82,7 +82,11 @@ pub const Service = struct { return error.CatalogRequestFailed; self.count = try parser.parseTitles(response.*.data[0..response.*.len], titles); - if (self.count == 0) return error.EmptyCatalog; + // A cloud catalog with zero titles is only a dead end when there's + // also no console to stream from (xHome); otherwise the CONSOLES + // tab still has something to show. + if (self.count == 0 and c.go_handheld_ui_console_count(self.uiHandle()) == 0) + return error.EmptyCatalog; self.applyCache(); try self.fetchMissingMetadata(); @@ -106,7 +110,8 @@ pub const Service = struct { pub fn pick(self: *Service, requested: []const u8) !PickResult { const titles = self.titles orelse return error.NotLoaded; - if (self.count == 0) return error.EmptyCatalog; + if (self.count == 0 and c.go_handheld_ui_console_count(self.uiHandle()) == 0) + return error.EmptyCatalog; var requested_buffer: [128]u8 = [_]u8{0} ** 128; if (requested.len >= requested_buffer.len) return error.InvalidRequestedTitle; diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h index e535fee..f0dd542 100644 --- a/src/ui/handheld_ui.h +++ b/src/ui/handheld_ui.h @@ -52,6 +52,8 @@ int go_handheld_ui_pick_title(GoHandheldUi* ui, const GoCatalogTitle* titles, in /* Provides the xHome consoles for the CONSOLES tab (copied; count is capped at * GO_UI_MAX_CONSOLES). count <= 0 hides the tab. */ void go_handheld_ui_set_consoles(GoHandheldUi* ui, const GoUiConsoleRow* rows, int count); +/* How many consoles go_handheld_ui_set_consoles last provided. */ +int go_handheld_ui_console_count(const GoHandheldUi* ui); int go_handheld_ui_cancelled(const GoHandheldUi* ui); /* Nonzero when Settings asks for the software video decoder. */ int go_handheld_ui_software_decoder(const GoHandheldUi* ui); diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 9687e45..91d73d6 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -388,7 +388,9 @@ fn updateArtwork(ui: *Ui, view: *const library.View, state: *ArtworkSelection) ? } fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_int { - if (titles.len == 0) return c.GO_HANDHELD_UI_PICK_CANCELLED; + // An empty title list is only a dead end without any console to fall + // back to; otherwise the CONSOLES tab still has something to pick. + if (titles.len == 0 and ui.console_count == 0) return c.GO_HANDHELD_UI_PICK_CANCELLED; ui.cancelled = false; const indices = std.heap.c_allocator.alloc(usize, titles.len) catch return c.GO_HANDHELD_UI_PICK_CANCELLED; defer std.heap.c_allocator.free(indices); @@ -662,9 +664,16 @@ pub export fn go_handheld_ui_pick_title( count: c_int, requested: [*c]const u8, ) c_int { - if (titles == null or count <= 0) return c.GO_HANDHELD_UI_PICK_CANCELLED; - const parsed_titles: [*]const library.Title = @ptrCast(@alignCast(titles)); - return pickTitle(ui orelse return c.GO_HANDHELD_UI_PICK_CANCELLED, parsed_titles[0..@intCast(count)], pointerString(requested) orelse ""); + const handle = ui orelse return c.GO_HANDHELD_UI_PICK_CANCELLED; + // A zero-title catalog is only a dead end when there's also nothing on + // the CONSOLES tab; otherwise the picker still has something to show. + if (count < 0 or (count == 0 and handle.console_count == 0) or + (count > 0 and titles == null)) return c.GO_HANDHELD_UI_PICK_CANCELLED; + const parsed_titles: []const library.Title = if (count > 0) + @as([*]const library.Title, @ptrCast(@alignCast(titles)))[0..@intCast(count)] + else + &.{}; + return pickTitle(handle, parsed_titles, pointerString(requested) orelse ""); } pub export fn go_handheld_ui_set_consoles(ui: ?*Ui, rows: [*c]const c.GoUiConsoleRow, count: c_int) void { @@ -681,6 +690,10 @@ pub export fn go_handheld_ui_set_consoles(ui: ?*Ui, rows: [*c]const c.GoUiConsol handle.console_count = limit; } +pub export fn go_handheld_ui_console_count(ui: ?*const Ui) c_int { + return @intCast((ui orelse return 0).console_count); +} + pub export fn go_handheld_ui_cancelled(ui: ?*const Ui) c_int { return @intFromBool(if (ui) |handle| handle.cancelled else false); } From a740d3f177bc1f4492c22650f72c75e6fa7973f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:22:08 +0000 Subject: [PATCH 12/13] video: clear the queued frame when smooth mode is disabled Disabling smooth video while a frame was already queued ahead of display_frame left that frame in place. publishFrame stops consulting it once smooth mode is off (it overwrites display_frame directly instead), but go_video_pipeline_render still checks queued_valid unconditionally and would promote that stale frame out of order on a later render. Clear queued_frame/queued_valid under frame_mutex when smooth mode turns off; enabling it still leaves any queued frame untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/media/video/video_pipeline.zig | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/media/video/video_pipeline.zig b/src/media/video/video_pipeline.zig index af4fe9d..87ac79e 100644 --- a/src/media/video/video_pipeline.zig +++ b/src/media/video/video_pipeline.zig @@ -669,7 +669,19 @@ pub export fn go_video_pipeline_push_rtp( pub export fn go_video_pipeline_set_smooth(pipeline_pointer: ?*Pipeline, smooth: c_int) void { const pipeline = pipeline_pointer orelse return; - pipeline.smooth.store(smooth != 0, .monotonic); + const enabled = smooth != 0; + pipeline.smooth.store(enabled, .monotonic); + if (enabled) return; + // Disabling smooth mode stops publishFrame from ever consulting + // queued_frame again, but go_video_pipeline_render still promotes it + // ahead of the next freshly decoded frame if it's left valid. Drop it + // so a stale, out-of-order frame can't be shown later. + pipeline.frame_mutex.lock(); + defer pipeline.frame_mutex.unlock(); + if (pipeline.queued_valid) { + c.av_frame_unref(pipeline.queued_frame); + pipeline.queued_valid = false; + } } pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void { From 73d954ab1f9e68fd1c4ce9357d69cbbf20a0350e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:22:15 +0000 Subject: [PATCH 13/13] ui: save the selected title right where CONSOLES tab clears it The previous fix for restoring title selection across the CONSOLES tab saved it in switchCollection, one step removed from where rebuild() actually discards self.count/self.selected. Moving the save into rebuild's .consoles branch, exactly where the list is zeroed, means any path that puts the view on CONSOLES keeps the invariant, not just switchCollection. A null preserve_title_index (e.g. a redundant rebuild call while already on CONSOLES, as handheld_ui.zig's START-button handler does) is treated as "nothing new to remember" rather than clearing the earlier save. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LmK4hLTaxP6Ud8EaAEDZzR --- src/ui/library_view.zig | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig index 3407c7e..6ae432e 100644 --- a/src/ui/library_view.zig +++ b/src/ui/library_view.zig @@ -46,7 +46,15 @@ pub const View = struct { self.selected = 0; // The consoles tab lists consoles, not titles: keep the title list empty // so nothing title-related (play/favorite/artwork/letter jump) can act. - if (self.collection == .consoles) return; + // Remember whatever title was selected on the way in (if any) right + // here, where the list gets zeroed, so switchCollection can restore + // it when leaving this tab. A null preserve_title_index means this + // call didn't come from a titles tab (e.g. a redundant rebuild while + // already on CONSOLES) and must not clobber an earlier save. + if (self.collection == .consoles) { + if (preserve_title_index) |index| self.saved_title_index = index; + return; + } for (self.titles, 0..) |*title, index| { if (self.collection == .favorites and !isFavorite(store, title)) continue; if (!search.matches(titleName(title), std.mem.sliceTo(&self.query, 0))) continue; @@ -98,7 +106,6 @@ pub const View = struct { .favorites => if (has_consoles) Collection.consoles else Collection.all, .consoles => Collection.all, }; - if (self.collection == .consoles) self.saved_title_index = preserve; self.rebuild(store, preserve); } @@ -431,3 +438,29 @@ test "switchCollection without visiting CONSOLES keeps prior behavior" { try std.testing.expectEqual(Collection.all, view.collection); try std.testing.expectEqual(expected, view.selectedTitleIndex().?); } + +test "a redundant rebuild while on CONSOLES doesn't clobber the saved title" { + // Mirrors handheld_ui.zig's START-button handler, which calls + // view.rebuild(store, view.selectedTitleIndex()) unconditionally after + // Settings closes - selectedTitleIndex() is null while on CONSOLES, so + // that rebuild must not overwrite the title saved on the way in. + const titles = [_]Title{ testTitle("Alpha"), testTitle("Bravo"), testTitle("Charlie") }; + var indices: [titles.len]usize = undefined; + const consoles = [_]ConsoleRow{std.mem.zeroes(ConsoleRow)}; + var store = try settings.Store.init(null); + var view = View{ .titles = &titles, .indices = &indices, .consoles = &consoles }; + view.rebuild(&store, null); + view.selected = 2; + const expected = view.selectedTitleIndex().?; + + view.switchCollection(&store, -1); // .all -> .consoles + try std.testing.expectEqual(Collection.consoles, view.collection); + + // A direct rebuild call while already on CONSOLES (preserve is null, + // same as view.selectedTitleIndex() would give here). + view.rebuild(&store, view.selectedTitleIndex()); + try std.testing.expectEqual(Collection.consoles, view.collection); + + view.switchCollection(&store, 1); // .consoles -> .all + try std.testing.expectEqual(expected, view.selectedTitleIndex().?); +}