diff --git a/README.md b/README.md index 2188625..a444626 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,38 @@ 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 | | 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 + +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 diff --git a/build.zig b/build.zig index d930e64..57f5b25 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", @@ -457,29 +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/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/app/release.zig b/src/app/release.zig index 0a18887..c61da7a 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,13 +54,26 @@ 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, + software_decoder: bool = false, catalog: ?*catalog_service.Service = null, curl_initialized: bool = false, requested_title: [128]u8 = [_]u8{0} ** 128, @@ -76,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, @@ -101,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; @@ -160,6 +189,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 +202,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 +268,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 +321,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 +345,19 @@ 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); + 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 +369,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 +393,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 +406,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 +422,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,12 +485,13 @@ 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; } 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) { @@ -402,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) @@ -441,7 +546,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,19 +606,27 @@ 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; + // 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; } @@ -522,6 +635,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..c02af66 100644 --- a/src/auth/xbox_auth.zig +++ b/src/auth/xbox_auth.zig @@ -14,7 +14,12 @@ 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"; +// 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; @@ -33,6 +38,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 +293,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 +416,42 @@ 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_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| { + _ = 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 +540,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 +558,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..99f698a --- /dev/null +++ b/src/catalog/consoles.zig @@ -0,0 +1,137 @@ +// 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); +} + +// 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(). 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, + 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_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 { + 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..0e67e6c 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, }; @@ -80,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(); @@ -104,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; @@ -117,6 +124,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/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, 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..87ac79e 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,14 @@ 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)) { + // 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); if (c.av_frame_ref(pipeline.display_frame, frame) == 0) pipeline.frame_ready.store(true, .release); @@ -425,23 +437,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 +480,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 +525,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 +598,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 +667,23 @@ 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; + 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 { const pipeline = pipeline_pointer orelse return; if (!pipeline.frame_ready.load(.acquire)) return; @@ -636,7 +691,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 +795,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); 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); 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..f0dd542 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,7 +49,16 @@ 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); +/* 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); +/* 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 1348151..91d73d6 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 { @@ -386,11 +388,17 @@ 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); - 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 +424,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 +436,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; @@ -652,15 +664,48 @@ 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 { + 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); + 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; +} + +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); } +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/library_view.zig b/src/ui/library_view.zig index a01ecf8..6ae432e 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,26 @@ 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, + // 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; 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. + // 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; @@ -51,6 +76,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 @@ -59,11 +92,20 @@ 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); + // 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) { + .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 +191,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 +267,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 +284,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; @@ -296,3 +392,75 @@ 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().?); +} + +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().?); +} 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"),