diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7136926..d363252 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Install host test dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends binutils libcurl4-openssl-dev libexpat1-dev libsdl2-dev pkg-config
- run: tools/bootstrap.sh
- run: tools/zig.sh build fmt-check
- run: tools/zig.sh build test
diff --git a/README.md b/README.md
index 2188625..7d6dcf2 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,16 @@
-GreenOvercast is a native Xbox Cloud Gaming client for small ARM64 Linux
-handhelds. It requests a stream matching the device display and uses hardware
-video decoding on supported H700 and Rockchip firmware.
+GreenOvercast is a native Xbox Cloud Gaming and GeForce NOW client for small
+ARM64 Linux handhelds, with hardware video decoding on supported H700 and
+Rockchip firmware.
-This independent project is not affiliated with or endorsed by Microsoft.
+This independent project is not affiliated with Microsoft or NVIDIA.
-Requires an [Xbox Game Pass plan](https://www.xbox.com/cloud-gaming) that includes cloud gaming and internet access.
+You need internet access and an account for your chosen service:
+
+- Xbox: an [Xbox Game Pass plan](https://www.xbox.com/cloud-gaming) that includes cloud gaming.
+- [GeForce NOW](https://www.nvidia.com/en-us/geforce-now/): a membership and access to the games you want to play.
## Install
@@ -19,9 +22,9 @@ the [PortMaster FAQ](https://portmaster.games/faq.html#do-i-have-to-use-portmast
When the installation finishes, return to the device frontend and launch
GreenOvercast from Ports.
-The first launch shows a Microsoft device code. Open
-[microsoft.com/link](https://www.microsoft.com/link) on another device and enter
-the code to sign in.
+Choose Xbox or GeForce NOW, then open the sign-in link shown on screen on
+another device and enter the code. Your login is saved. You can switch services
+from Settings without closing the app.
GreenOvercast is experimental.
@@ -44,28 +47,46 @@ 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.
+GeForce NOW uses the same gamepad controls. For store dialogs and game
+launchers, press Select + Y to toggle mouse mode: D-pad or left stick moves the
+pointer, A clicks, B right-clicks, and L1 / R1 scroll. Store sign-in may still be
+required inside the stream.
+
+GeForce NOW Free sessions may show ads before the game. Start opens ad information
+when an icon is shown; B returns to the ad or cancels playback.
+
## Supported devices
-| Device | OS | Status |
-| ---------- | ------------------------------- | ------ |
-| RG35XX-H | muOS 2508.4 | Tested |
-| RG40XX-H | Knulli (Batocera 42) | Tested |
-| RG40XX-H | ROCKNIX 20260801 | Tested |
-| Miyoo Flip | SpruceOS 4.2.0 | Tested |
-| R36S | AmberELEC prerelease-20250515 | Tested |
-
-Hardware decoding is verified on the H700 systems above. muOS and Knulli use
-CedarX; ROCKNIX 20260801 uses the bundled Cedrus modules. Tested Rockchip builds
-use Rockchip MPP on RK3566 with SpruceOS and RK3326 with AmberELEC. Other
+| Device | SoC | OS | Status |
+| ---------- | --------------- | ----------------------------- | ------ |
+| RG35XX-H | Allwinner H700 | muOS 2508.4 | Tested |
+| RG40XX-H | Allwinner H700 | Knulli (Batocera 42) | Tested |
+| RG40XX-H | Allwinner H700 | ROCKNIX 20260801 | Tested |
+| Miyoo Flip | Rockchip RK3566 | SpruceOS 4.3.3 | Tested |
+| R36S | Rockchip RK3326 | AmberELEC prerelease-20250515 | Tested |
+
+Hardware decoding is verified on the configurations above. H700 uses CedarX on
+muOS and Knulli, and Cedrus on ROCKNIX. RK3566 and RK3326 use Rockchip MPP. Other
devices fall back to software decoding, which is too slow for normal gameplay.
-The release requires glibc 2.38 or newer. ArkOS ships glibc 2.30 and is not
-supported.
+GeForce NOW playback has been tested on muOS, Knulli, SpruceOS, and the R36S
+running dArkOS 02012026. R36S frame pacing still needs work. The ROCKNIX and
+AmberELEC rows above currently cover Xbox playback.
+
+The release requires glibc 2.38 or newer. ArkOS (glibc 2.30) is not supported;
+the tested dArkOS fork uses glibc 2.41.
## Build
-You need a Linux or macOS host with `cmake`, `curl`, `git`, `make`, `patch`,
-`perl`, and `python3`.
+You need a Linux or macOS host with CMake 3.24+, `curl`, `git`, `make`, `patch`,
+`perl`, `pkg-config`, `python3`, and `tar`.
+
+Build validation also needs `llvm-readelf` or GNU `readelf` (`binutils` on
+Debian/Ubuntu, or `llvm` with its `bin` directory on `PATH` on macOS).
+
+Host tests also need SDL2, curl and Expat development files: install
+`libsdl2-dev libcurl4-openssl-dev libexpat1-dev pkg-config` on Debian/Ubuntu or
+`sdl2 curl expat pkg-config` with Homebrew.
```sh
tools/bootstrap.sh
diff --git a/THIRDPARTY.md b/THIRDPARTY.md
index e4ba393..c0b2a63 100644
--- a/THIRDPARTY.md
+++ b/THIRDPARTY.md
@@ -15,9 +15,14 @@ release. Full license texts for the bundled libraries ship alongside the port in
| [CedarX](https://github.com/allwinner-zh/media-codec) (Allwinner H.264 decoder) | LGPLv2.1-or-later | Hardware H.264 decode (shared) |
| [Linux Cedrus](https://www.kernel.org/) (7.0.11) | GPL-2.0-only | ROCKNIX H700 V4L2 Request decoder modules |
| [Rockchip MPP](https://github.com/rockchip-linux/mpp) (v1.1.0) | Apache-2.0/MIT | Optional Rockchip H.264 decode (shared) |
-| [FFmpeg](https://ffmpeg.org/) (libavcodec, libavutil, libswscale) | LGPLv2.1-or-later | H.264 decode, JPEG artwork, and color conversion |
+| [FFmpeg](https://ffmpeg.org/) | LGPLv2.1-or-later | Video/audio decode, MP4 ads, artwork, and color conversion |
+| [Expat](https://libexpat.github.io/) | MIT | VAST XML parsing (static) |
+| [zlib](https://zlib.net/) | Zlib | PNG decompression (static into FFmpeg) |
| [libudev-zero](https://github.com/illiliti/libudev-zero) | ISC | V4L2 media-device discovery (static) |
| [SDL2](https://www.libsdl.org/) | Zlib | Window, renderer, audio, and controller input (device) |
| [OpenSSL](https://www.openssl.org/) | Apache-2.0 | TLS, WebRTC DTLS, and credential encryption (static) |
-| [libcurl](https://curl.se/) | curl (MIT-style) | HTTP for Xbox services (static) |
+| [libcurl](https://curl.se/) | curl (MIT-style) | HTTP and WebSocket transport (static) |
| [Opus](https://opus-codec.org/) | BSD-3-Clause | Audio decode (static) |
+
+Xbox and NVIDIA icons are from [Bootstrap Icons](https://github.com/twbs/icons)
+(MIT).
diff --git a/build.zig b/build.zig
index d930e64..0953c82 100644
--- a/build.zig
+++ b/build.zig
@@ -3,15 +3,17 @@ const std = @import("std");
// glibc 2.38 is the oldest userspace supported by the packaged release.
const aarch64_linux_query: std.Target.Query = .{
.cpu_arch = .aarch64,
+ // Keep baseline ARM64 atomics independent of firmware runtime helpers.
+ .cpu_features_sub = std.Target.aarch64.featureSet(&.{.outline_atomics}),
.os_tag = .linux,
.abi = .gnu,
.glibc_version = .{ .major = 2, .minor = 38, .patch = 0 },
};
const c_test_flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror" };
+const aarch64_c_test_flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror", "-mno-outline-atomics" };
const project_include_paths = [_][]const u8{
- ".tools/deps/aarch64-linux-gnu/include",
"vendor/libdatachannel/include",
"src/media/audio",
"src/media/video",
@@ -51,6 +53,7 @@ const release_zig_roots = [_]struct {
.imports = &.{
.{ .name = "json_reader", .path = "src/net/json_reader.zig" },
.{ .name = "json_writer", .path = "src/net/json_writer.zig" },
+ .{ .name = "uuid", .path = "src/util/uuid.zig" },
},
},
.{ .name = "greenovercast-controller", .path = "src/input/controller.zig" },
@@ -81,6 +84,7 @@ const release_c_sources = [_][]const u8{
"src/media/video/video_decoder_v4l2_request.c",
"src/auth/token_store_adapter.c",
"src/net/http_client.c",
+ "src/net/websocket_client.c",
"src/ui/artwork_decoder.c",
};
@@ -122,6 +126,7 @@ fn addReleaseZigObject(
.link_libc = true,
});
addProjectIncludes(b, module);
+ module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include"));
for (imports) |item|
addZigImport(b, module, target, optimize, item.name, item.path);
return b.addObject(.{ .name = name, .root_module = module });
@@ -135,17 +140,52 @@ fn addZigImport(
name: []const u8,
source: []const u8,
) void {
- module.addImport(name, b.createModule(.{
+ const imported = b.createModule(.{
.root_source_file = b.path(source),
.target = target,
.optimize = optimize,
- }));
+ });
+ if (std.mem.eql(u8, name, "xml_document") or std.mem.eql(u8, name, "clip_player"))
+ imported.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include"));
+ module.addImport(name, imported);
}
fn addStaticArchive(module: *std.Build.Module, b: *std.Build, path: []const u8) void {
module.addObjectFile(b.path(path));
}
+fn addHostUnitTest(
+ b: *std.Build,
+ test_step: *std.Build.Step,
+ source: []const u8,
+ project_includes: bool,
+ sdl: bool,
+ imports: []const ZigImport,
+) void {
+ const module = b.createModule(.{
+ .root_source_file = b.path(source),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ .link_libc = project_includes,
+ });
+ if (project_includes) addProjectIncludes(b, module);
+ if (sdl) module.linkSystemLibrary("SDL2", .{ .use_pkg_config = .force });
+ if (std.mem.endsWith(u8, source, "/vast.zig") or std.mem.endsWith(u8, source, "/ad_policy.zig") or
+ std.mem.endsWith(u8, source, "/ad_network.zig"))
+ addZigImport(b, module, b.graph.host, .Debug, "xml_document", "src/net/xml_document.zig");
+ if (std.mem.endsWith(u8, source, "/vast.zig") or std.mem.endsWith(u8, source, "/xml_document.zig") or
+ std.mem.endsWith(u8, source, "/ad_policy.zig") or std.mem.endsWith(u8, source, "/ad_network.zig"))
+ module.linkSystemLibrary("expat", .{ .use_pkg_config = .force });
+ if (std.mem.endsWith(u8, source, "/ad_network.zig")) {
+ module.addCSourceFile(.{ .file = b.path("src/net/http_client.c"), .flags = &.{ "-std=gnu11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror" } });
+ module.linkSystemLibrary("curl", .{ .use_pkg_config = .force });
+ }
+ for (imports) |item|
+ addZigImport(b, module, b.graph.host, .Debug, item.name, item.path);
+ const unit_tests = b.addTest(.{ .root_module = module });
+ test_step.dependOn(&b.addRunArtifact(unit_tests).step);
+}
+
fn addReleaseArtifacts(
b: *std.Build,
target: std.Build.ResolvedTarget,
@@ -166,11 +206,38 @@ fn addReleaseArtifacts(
.strip = true,
});
addProjectIncludes(b, main_module);
+ main_module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include"));
+ addZigImport(b, main_module, target, optimize, "xml_document", "src/net/xml_document.zig");
+ addZigImport(b, main_module, target, optimize, "clip_player", "src/media/clip/player.zig");
+ addZigImport(
+ b,
+ main_module,
+ target,
+ optimize,
+ "form_writer",
+ "src/net/form_writer.zig",
+ );
+ addZigImport(
+ b,
+ main_module,
+ target,
+ optimize,
+ "catalog_parser",
+ "src/catalog/catalog_parser.zig",
+ );
+ addZigImport(
+ b,
+ main_module,
+ target,
+ optimize,
+ "uuid",
+ "src/util/uuid.zig",
+ );
main_module.addLibraryPath(b.path(".tools/deps/aarch64-linux-gnu/lib"));
main_module.addRPathSpecial("$ORIGIN");
main_module.addCSourceFiles(.{
.files = &release_c_sources,
- .flags = &.{ "-std=gnu11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror" },
+ .flags = &.{ "-std=gnu11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-mno-outline-atomics" },
});
for (release_zig_roots) |root| {
@@ -188,7 +255,8 @@ fn addReleaseArtifacts(
addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libssl.a");
addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libcrypto.a");
addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libopus.a");
- for ([_][]const u8{ "pthread", "dl", "SDL2", "avcodec", "avutil", "swscale" }) |library| {
+ addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libexpat.a");
+ for ([_][]const u8{ "pthread", "dl", "SDL2", "avcodec", "avutil", "swscale", "avformat", "swresample" }) |library| {
main_module.linkSystemLibrary(library, .{ .use_pkg_config = .no });
}
@@ -217,6 +285,7 @@ fn addReleaseArtifacts(
.files = &cedar_sources,
.flags = &.{
"-std=gnu11",
+ "-mno-outline-atomics",
"-D_GNU_SOURCE",
"-includestdint.h",
"-fvisibility=hidden",
@@ -244,7 +313,7 @@ fn addReleaseArtifacts(
cedar_test_module.addIncludePath(b.path("src/media/video"));
cedar_test_module.addCSourceFile(.{
.file = b.path("tests/device/cedar_bridge_test.c"),
- .flags = c_test_flags,
+ .flags = aarch64_c_test_flags,
});
cedar_test_module.linkLibrary(cedar);
cedar_test_module.addRPathSpecial("$ORIGIN");
@@ -267,7 +336,7 @@ fn addReleaseArtifacts(
mpp_module.addRPathSpecial("$ORIGIN");
mpp_module.addCSourceFile(.{
.file = b.path("src/media/video/mpp_bridge.c"),
- .flags = c_test_flags,
+ .flags = aarch64_c_test_flags,
});
mpp_module.linkSystemLibrary("rockchip_mpp", .{
.use_pkg_config = .no,
@@ -316,7 +385,7 @@ fn addReleaseArtifacts(
});
module.addIncludePath(b.path("src/media/video"));
module.addRPathSpecial("$ORIGIN");
- module.addCSourceFile(.{ .file = b.path(item.source), .flags = c_test_flags });
+ module.addCSourceFile(.{ .file = b.path(item.source), .flags = aarch64_c_test_flags });
module.addObject(mpp_loader_device_object);
module.linkSystemLibrary("dl", .{ .use_pkg_config = .no });
const tool = b.addExecutable(.{ .name = item.name, .root_module = module });
@@ -330,6 +399,8 @@ fn addReleaseArtifacts(
.{ .source = ".tools/deps/aarch64-linux-gnu/lib/libavcodec.so.63", .name = "libavcodec.so.63" },
.{ .source = ".tools/deps/aarch64-linux-gnu/lib/libavutil.so.61", .name = "libavutil.so.61" },
.{ .source = ".tools/deps/aarch64-linux-gnu/lib/libswscale.so.10", .name = "libswscale.so.10" },
+ .{ .source = ".tools/deps/aarch64-linux-gnu/lib/libavformat.so.63", .name = "libavformat.so.63" },
+ .{ .source = ".tools/deps/aarch64-linux-gnu/lib/libswresample.so.7", .name = "libswresample.so.7" },
};
var private_library_installs: [private_libraries.len]*std.Build.Step.InstallFile = undefined;
for (private_libraries, 0..) |library, index| {
@@ -341,14 +412,33 @@ fn addReleaseArtifacts(
private_library_installs[index] = b.addInstallBinFile(copy.getOutput(), library.name);
}
+ const runtime_check = b.addSystemCommand(&.{
+ "sh",
+ b.pathFromRoot("tools/check-aarch64-runtime.sh"),
+ b.getInstallPath(.bin, "webrtc_stream"),
+ b.getInstallPath(.lib, "libgreenovercast-cedar.so"),
+ b.getInstallPath(.bin, "cedar_bridge_test"),
+ b.getInstallPath(.prefix, "rockchip/libgreenovercast-mpp.so"),
+ b.getInstallPath(.prefix, "rockchip/librockchip_mpp.so.1"),
+ b.getInstallPath(.prefix, "rockchip/greenovercast-mpp-probe.aarch64"),
+ b.getInstallPath(.prefix, "rockchip/greenovercast-mpp-bridge-test.aarch64"),
+ b.getInstallPath(.bin, "libavcodec.so.63"),
+ b.getInstallPath(.bin, "libavutil.so.61"),
+ b.getInstallPath(.bin, "libswscale.so.10"),
+ b.getInstallPath(.bin, "libavformat.so.63"),
+ b.getInstallPath(.bin, "libswresample.so.7"),
+ });
+ runtime_check.setCwd(b.path("."));
+ runtime_check.step.dependOn(&install_executable.step);
+ runtime_check.step.dependOn(&install_cedar.step);
+ runtime_check.step.dependOn(&install_cedar_test.step);
+ runtime_check.step.dependOn(&install_mpp.step);
+ runtime_check.step.dependOn(&install_mpp_runtime.step);
+ for (mpp_device_installs) |install| runtime_check.step.dependOn(&install.step);
+ for (private_library_installs) |install| runtime_check.step.dependOn(&install.step);
+
const release = b.step("release", "Build the aarch64 application and decoder plugins");
- release.dependOn(&install_executable.step);
- release.dependOn(&install_cedar.step);
- release.dependOn(&install_cedar_test.step);
- release.dependOn(&install_mpp.step);
- release.dependOn(&install_mpp_runtime.step);
- for (mpp_device_installs) |install| release.dependOn(&install.step);
- for (private_library_installs) |install| release.dependOn(&install.step);
+ release.dependOn(&runtime_check.step);
return release;
}
@@ -407,6 +497,39 @@ pub fn build(b: *std.Build) void {
const release = addReleaseArtifacts(b, aarch64_linux, .ReleaseSafe);
b.default_step.dependOn(release);
+ const ad_test_module = b.createModule(.{
+ .root_source_file = b.path("tests/device/ad_playback_test.zig"),
+ .target = aarch64_linux,
+ .optimize = .ReleaseSafe,
+ .link_libc = true,
+ });
+ addProjectIncludes(b, ad_test_module);
+ ad_test_module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include"));
+ ad_test_module.addLibraryPath(b.path(".tools/deps/aarch64-linux-gnu/lib"));
+ const ads_module = b.createModule(.{
+ .root_source_file = b.path("src/provider/geforce_now/ad_playback.zig"),
+ .target = aarch64_linux,
+ .optimize = .ReleaseSafe,
+ });
+ addProjectIncludes(b, ads_module);
+ ads_module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include"));
+ for ([_]ZigImport{
+ .{ .name = "xml_document", .path = "src/net/xml_document.zig" },
+ .{ .name = "clip_player", .path = "src/media/clip/player.zig" },
+ .{ .name = "uuid", .path = "src/util/uuid.zig" },
+ }) |item| addZigImport(b, ads_module, aarch64_linux, .ReleaseSafe, item.name, item.path);
+ ad_test_module.addImport("gfn_ads", ads_module);
+ ad_test_module.addCSourceFiles(.{ .files = &.{ "src/ui/artwork_decoder.c", "src/net/http_client.c" }, .flags = aarch64_c_test_flags });
+ for ([_][]const u8{ "avcodec", "avformat", "avutil", "swresample", "swscale", "SDL2", "pthread" }) |library|
+ ad_test_module.linkSystemLibrary(library, .{ .use_pkg_config = .no });
+ for ([_][]const u8{ "expat", "curl", "ssl", "crypto" }) |library|
+ addStaticArchive(ad_test_module, b, b.fmt(".tools/deps/aarch64-linux-gnu/lib/lib{s}.a", .{library}));
+ ad_test_module.addRPathSpecial("$ORIGIN");
+ const ad_test = b.addExecutable(.{ .name = "greenovercast-ad-test.aarch64", .root_module = ad_test_module });
+ ad_test.step.dependOn(release);
+ const ad_test_step = b.step("ad-player-test", "Build the aarch64 ad playback device test");
+ ad_test_step.dependOn(&b.addInstallArtifact(ad_test, .{}).step);
+
const product_check = b.step("product-check", "Build the complete aarch64 product");
product_check.dependOn(release);
@@ -435,7 +558,7 @@ pub fn build(b: *std.Build) void {
smoke.root_module.addImport("stat_compat", stat_compat);
smoke.addCSourceFile(.{
.file = b.path("src/smoke/cpp_probe.cpp"),
- .flags = &.{"-std=c++17"},
+ .flags = &.{ "-std=c++17", "-mno-outline-atomics" },
});
smoke.linkLibCpp();
@@ -456,32 +579,121 @@ 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 dependency_toolchain_test = b.addSystemCommand(&.{
+ "sh",
+ b.pathFromRoot("tests/dependency_toolchain_test.sh"),
+ });
+ dependency_toolchain_test.setCwd(b.path("."));
+ test_step.dependOn(&dependency_toolchain_test.step);
+ const aarch64_runtime_test = b.addSystemCommand(&.{
+ "sh",
+ b.pathFromRoot("tests/aarch64_runtime_test.sh"),
+ });
+ aarch64_runtime_test.setCwd(b.path("."));
+ test_step.dependOn(&aarch64_runtime_test.step);
+
+ const tests = [_]struct {
+ source: []const u8,
+ project_includes: bool = false,
+ sdl: bool = false,
+ imports: []const ZigImport = &.{},
+ }{
+ .{ .source = "src/app/state.zig" },
+ .{ .source = "src/catalog/catalog_parser.zig" },
+ .{ .source = "src/catalog/catalog_search.zig" },
+ .{ .source = "src/input/wire_encoder.zig" },
+ .{ .source = "src/input/guide_chord.zig" },
+ .{ .source = "src/session/message_protocol.zig" },
+ .{ .source = "src/ui/keyboard.zig" },
+ .{ .source = "src/ui/control_icons.zig" },
+ .{ .source = "src/ui/navigation_repeat.zig" },
+ .{ .source = "src/ui/persistent_settings.zig" },
+ .{ .source = "src/ui/stream_dimensions.zig" },
+ .{ .source = "src/media/rtp/h264_depacketizer.zig" },
+ .{ .source = "src/net/json_reader.zig" },
+ .{ .source = "src/net/json_writer.zig" },
+ .{ .source = "src/net/form_writer.zig" },
+ .{ .source = "src/util/uuid.zig" },
+ .{ .source = "src/provider/geforce_now/input_protocol.zig" },
+ .{ .source = "src/provider/geforce_now/pointer_input.zig" },
+ .{ .source = "src/provider/geforce_now/endpoint.zig" },
+ .{ .source = "src/provider/geforce_now/cloudmatch_protocol.zig" },
+ .{ .source = "src/provider/geforce_now/ad_protocol.zig" },
+ .{ .source = "src/provider/geforce_now/ad_policy.zig", .project_includes = true },
+ .{ .source = "src/provider/geforce_now/ad_network.zig", .project_includes = true },
+ .{ .source = "src/net/xml_document.zig", .project_includes = true },
+ .{ .source = "src/provider/geforce_now/vast.zig", .project_includes = true },
+ .{ .source = "src/provider/geforce_now/subscription_protocol.zig" },
+ .{ .source = "src/provider/geforce_now/provider_protocol.zig" },
+ .{ .source = "src/provider/geforce_now/signaling_protocol.zig" },
+ .{ .source = "src/provider/geforce_now/sdp_protocol.zig" },
+ .{
+ .source = "src/provider/geforce_now/auth_protocol.zig",
+ .imports = &.{.{ .name = "form_writer", .path = "src/net/form_writer.zig" }},
+ },
+ .{
+ .source = "src/provider/geforce_now/auth_client.zig",
+ .project_includes = true,
+ .sdl = true,
+ .imports = &.{
+ .{ .name = "gfn_http_fake", .path = "tests/gfn_http_fake.zig" },
+ .{ .name = "form_writer", .path = "src/net/form_writer.zig" },
+ .{ .name = "uuid", .path = "src/util/uuid.zig" },
+ },
+ },
+ .{
+ .source = "src/provider/geforce_now/catalog_protocol.zig",
+ .imports = &.{.{ .name = "catalog_parser", .path = "src/catalog/catalog_parser.zig" }},
+ },
+ .{
+ .source = "src/provider/geforce_now/catalog_service.zig",
+ .project_includes = true,
+ .sdl = true,
+ .imports = &.{
+ .{ .name = "gfn_http_fake", .path = "tests/gfn_http_fake.zig" },
+ .{ .name = "form_writer", .path = "src/net/form_writer.zig" },
+ .{ .name = "catalog_parser", .path = "src/catalog/catalog_parser.zig" },
+ .{ .name = "uuid", .path = "src/util/uuid.zig" },
+ },
+ },
+ .{
+ .source = "src/provider/geforce_now/session_client.zig",
+ .project_includes = true,
+ .sdl = true,
+ .imports = &.{
+ .{ .name = "gfn_http_fake", .path = "tests/gfn_http_fake.zig" },
+ .{ .name = "form_writer", .path = "src/net/form_writer.zig" },
+ .{ .name = "uuid", .path = "src/util/uuid.zig" },
+ },
+ },
+ .{
+ .source = "src/provider/geforce_now/webrtc_session.zig",
+ .project_includes = true,
+ .sdl = true,
+ },
};
- for (test_roots) |root| {
- const unit_tests = b.addTest(.{
- .root_source_file = b.path(root),
- .target = b.graph.host,
- .optimize = .Debug,
- });
- test_step.dependOn(&b.addRunArtifact(unit_tests).step);
- }
+ for (tests) |test_config|
+ addHostUnitTest(
+ b,
+ test_step,
+ test_config.source,
+ test_config.project_includes,
+ test_config.sdl,
+ test_config.imports,
+ );
+
+ const http_test = b.addTest(.{
+ .root_source_file = b.path("tests/http_client_test.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ });
+ http_test.addIncludePath(b.path("src/net"));
+ http_test.addCSourceFile(.{ .file = b.path("src/net/http_client.c"), .flags = c_test_flags });
+ http_test.linkLibC();
+ http_test.linkSystemLibrary("curl");
+ const run_http_test = b.addRunArtifact(http_test);
+ run_http_test.setEnvironmentVariable("NO_PROXY", "127.0.0.1,localhost");
+ test_step.dependOn(&run_http_test.step);
const video_decoder_object =
addHostZigObject(b, "video-decoder", "src/media/video/video_decoder.zig");
@@ -536,6 +748,20 @@ pub fn build(b: *std.Build) void {
const fake_cedar_valid =
addHostCFakeLibrary(b, "fake-cedar-valid", "tests/cedar_fake_valid.c");
+ const cedar_list_test = b.addExecutable(.{
+ .name = "cedar-list-test",
+ .root_source_file = null,
+ .target = b.graph.host,
+ .optimize = .ReleaseSafe,
+ });
+ cedar_list_test.addIncludePath(b.path("vendor/cedarx/base/include"));
+ cedar_list_test.addCSourceFile(.{
+ .file = b.path("tests/cedar_list_test.c"),
+ .flags = &.{ "-std=gnu11", "-Wall", "-Wextra", "-Werror" },
+ });
+ cedar_list_test.linkLibC();
+ test_step.dependOn(&b.addRunArtifact(cedar_list_test).step);
+
const cedar_decoder_test = addHostCExecutable(
b,
"video-decoder-cedar-test",
diff --git a/packaging/portmaster/greenovercast/GreenOvercast.sh b/packaging/portmaster/greenovercast/GreenOvercast.sh
index f88823d..8decca6 100644
--- a/packaging/portmaster/greenovercast/GreenOvercast.sh
+++ b/packaging/portmaster/greenovercast/GreenOvercast.sh
@@ -57,6 +57,9 @@ chmod 700 "$credential_dir" || fail "Unable to protect GreenOvercast's private s
credential_file="$credential_dir/tokens.bin"
credential_key_file="$credential_dir/tokens.key"
+gfn_credential_file="$credential_dir/geforce-now-tokens.bin"
+gfn_credential_key_file="$credential_dir/geforce-now-tokens.key"
+gfn_device_id_file="$credential_dir/geforce-now-device-id"
video_bootstrap_file="$credential_dir/h264-parameter-sets.bin"
catalog_file="$credential_dir/catalog.tsv"
settings_file="$config_dir/settings.tsv"
@@ -66,7 +69,9 @@ log_file="$credential_dir/greenovercast.log"
chmod 600 "$log_file"
exec > >(tee "$log_file") 2>&1
-for private_file in "$credential_file" "$credential_key_file" "$catalog_file"; do
+for private_file in "$credential_file" "$credential_key_file" \
+ "$gfn_credential_file" "$gfn_credential_key_file" "$gfn_device_id_file" \
+ "$catalog_file"; do
[ ! -e "$private_file" ] || chmod 600 "$private_file"
done
@@ -78,6 +83,13 @@ fi
export GREENOVERCAST_TOKEN_FILE="$credential_file"
export GREENOVERCAST_TOKEN_KEY_FILE="$credential_key_file"
+export GREENOVERCAST_GFN_TOKEN_FILE="$gfn_credential_file"
+export GREENOVERCAST_GFN_TOKEN_KEY_FILE="$gfn_credential_key_file"
+export GREENOVERCAST_GFN_DEVICE_ID_FILE="$gfn_device_id_file"
+[ ! -f "$config_dir/geforce-now-oauth-client-id" ] ||
+ export GREENOVERCAST_GFN_CLIENT_ID_FILE="$config_dir/geforce-now-oauth-client-id"
+[ ! -f "$config_dir/geforce-now-protocol-client-id" ] ||
+ export GREENOVERCAST_GFN_PROTOCOL_CLIENT_ID_FILE="$config_dir/geforce-now-protocol-client-id"
export GREENOVERCAST_H264_BOOTSTRAP_FILE="$video_bootstrap_file"
export GREENOVERCAST_CATALOG_FILE="$catalog_file"
export GREENOVERCAST_SETTINGS_FILE="$settings_file"
diff --git a/packaging/portmaster/greenovercast/README.md b/packaging/portmaster/greenovercast/README.md
index f5aaba4..d7cdac4 100644
--- a/packaging/portmaster/greenovercast/README.md
+++ b/packaging/portmaster/greenovercast/README.md
@@ -1,15 +1,17 @@
## Notes
-GreenOvercast is an unofficial Xbox Cloud Gaming client for Linux handhelds.
-It requires an Xbox Game Pass plan that includes cloud gaming and internet
-access.
+GreenOvercast is an unofficial Xbox Cloud Gaming and GeForce NOW client for
+Linux handhelds. You need internet access and an account for your chosen
+service. Xbox requires a Game Pass plan that includes cloud gaming; GeForce
+NOW requires a membership and access to the games you want to play.
-The first launch shows a Microsoft device code. Open
-`https://www.microsoft.com/link` on another device and enter the code to sign
-in.
+Choose a service, then open the sign-in link shown on screen on another device
+and enter the code. Your login is saved. You can switch services from Settings.
Tested on the RG35XX-H with muOS, the RG40XX-H with Knulli and ROCKNIX
20260801, the Miyoo Flip with SpruceOS, and the R36S with AmberELEC.
+GeForce NOW has been tested on muOS, Knulli, SpruceOS, and dArkOS on the R36S.
+R36S frame pacing still needs work; ROCKNIX and AmberELEC testing covers Xbox.
The release requires glibc 2.38 or newer. ArkOS is not supported.
## Controls
@@ -30,3 +32,8 @@ The release requires glibc 2.38 or newer. ArkOS is not supported.
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.
+
+GeForce NOW uses the same gamepad controls. For store dialogs and game
+launchers, press Select + Y to toggle mouse mode: D-pad or left stick moves the
+pointer, A clicks, B right-clicks, and L1 / R1 scroll. Store sign-in may still be
+required inside the stream.
diff --git a/packaging/portmaster/greenovercast/gameinfo.xml b/packaging/portmaster/greenovercast/gameinfo.xml
index b1ef1ab..4a95e47 100644
--- a/packaging/portmaster/greenovercast/gameinfo.xml
+++ b/packaging/portmaster/greenovercast/gameinfo.xml
@@ -3,7 +3,7 @@
./GreenOvercast.sh
GreenOvercast
- A native Xbox Cloud Gaming client with a searchable, controller-first library for Linux handhelds.
+ An unofficial Xbox Cloud Gaming and GeForce NOW client with a searchable, controller-first library for Linux handhelds.
20260726T000000
GreenOvercast
GreenOvercast
diff --git a/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md b/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md
index 05a069a..bc3c13b 100644
--- a/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md
+++ b/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md
@@ -11,6 +11,7 @@ recipe is in `build.zig`.
The maintained changes are limited to the aarch64/H616 port:
- pointer-width-safe register addresses;
+- standard member-offset calculation for the linked-list helpers;
- preservation of 32-bit H616 IOMMU addresses;
- the H616 VE-version query;
- corrected initialization, teardown, and codec-registration error paths;
diff --git a/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md b/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md
index cc98442..dd8b52a 100644
--- a/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md
+++ b/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md
@@ -1,7 +1,7 @@
# FFmpeg
-GreenOvercast ships `libavcodec`, `libavutil`, and `libswscale` from FFmpeg
-9.0 under LGPLv2.1-or-later.
+GreenOvercast ships `libavcodec`, `libavformat`, `libavutil`, `libswresample`,
+and `libswscale` from FFmpeg 9.0 under LGPLv2.1-or-later.
Source:
diff --git a/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Bootstrap-Icons.txt b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Bootstrap-Icons.txt
new file mode 100644
index 0000000..f952439
--- /dev/null
+++ b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Bootstrap-Icons.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2019-2024 The Bootstrap Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Expat.txt b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Expat.txt
new file mode 100644
index 0000000..c6d184a
--- /dev/null
+++ b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Expat.txt
@@ -0,0 +1,21 @@
+Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper
+Copyright (c) 2001-2025 Expat maintainers
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.zlib.txt b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.zlib.txt
new file mode 100644
index 0000000..b7a69d0
--- /dev/null
+++ b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.zlib.txt
@@ -0,0 +1,22 @@
+Copyright notice:
+
+ (C) 1995-2026 Jean-loup Gailly and Mark Adler
+
+ This software is provided 'as-is', without any express or implied
+ warranty. In no event will the authors be held liable for any damages
+ arising from the use of this software.
+
+ Permission is granted to anyone to use this software for any purpose,
+ including commercial applications, and to alter it and redistribute it
+ freely, subject to the following restrictions:
+
+ 1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+ 2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+ 3. This notice may not be removed or altered from any source distribution.
+
+ Jean-loup Gailly Mark Adler
+ jloup@gzip.org madler@alumni.caltech.edu
diff --git a/packaging/portmaster/greenovercast/port.json b/packaging/portmaster/greenovercast/port.json
index 8aa7986..85eb048 100644
--- a/packaging/portmaster/greenovercast/port.json
+++ b/packaging/portmaster/greenovercast/port.json
@@ -11,10 +11,10 @@
"porter": [
"Producdevity"
],
- "desc": "An unofficial native Xbox Cloud Gaming client with a searchable, controller-first library for Linux handhelds.",
+ "desc": "An unofficial Xbox Cloud Gaming and GeForce NOW client with a searchable, controller-first library for Linux handhelds.",
"desc_md": null,
- "inst": "Requires an Xbox Game Pass plan that includes cloud gaming and internet access. First launch uses Microsoft's device-code sign-in.",
- "inst_md": "Requires an Xbox Game Pass plan that includes cloud gaming and internet access. First launch uses Microsoft's device-code sign-in.",
+ "inst": "Requires internet access and either a Game Pass plan with cloud gaming, or a GeForce NOW membership and access to the games you want to play. Choose a service and follow the on-screen sign-in instructions.",
+ "inst_md": "Requires internet access and either a Game Pass plan with cloud gaming, or a GeForce NOW membership and access to the games you want to play. Choose a service and follow the on-screen sign-in instructions.",
"genres": [
"other"
],
diff --git a/src/app/release.zig b/src/app/release.zig
index 0a18887..369187c 100644
--- a/src/app/release.zig
+++ b/src/app/release.zig
@@ -1,5 +1,10 @@
const std = @import("std");
const catalog_service = @import("../catalog/service.zig");
+const geforce_auth = @import("../provider/geforce_now/auth_client.zig");
+const geforce_catalog = @import("../provider/geforce_now/catalog_service.zig");
+const geforce_session = @import("../provider/geforce_now/session_client.zig");
+const geforce_webrtc = @import("../provider/geforce_now/webrtc_session.zig");
+const geforce_ads = @import("../provider/geforce_now/ad_playback.zig");
const c = @cImport({
@cInclude("SDL2/SDL.h");
@@ -21,11 +26,25 @@ pub const Result = enum {
missing_credentials,
reauth_required,
signed_out,
+ change_provider,
failed,
};
+const Provider = enum {
+ xbox,
+ geforce_now,
+};
+
var stop_requested: c_int = 0;
+fn parseProvider(value: []const u8) ?Provider {
+ if (std.ascii.eqlIgnoreCase(value, "xbox") or
+ std.ascii.eqlIgnoreCase(value, "xbox-cloud-gaming")) return .xbox;
+ if (std.ascii.eqlIgnoreCase(value, "geforce-now") or
+ std.ascii.eqlIgnoreCase(value, "gfn")) return .geforce_now;
+ return null;
+}
+
fn handleStopSignal(_: c_int) callconv(.c) void {
const requested: *volatile c_int = &stop_requested;
requested.* = 1;
@@ -61,9 +80,15 @@ pub const Release = struct {
cloud: ?*c.GoCloudSession = null,
webrtc: ?*c.GoWebrtcSession = null,
catalog: ?*catalog_service.Service = null,
+ geforce_auth: ?*geforce_auth.Client = null,
+ geforce_cloud: ?*geforce_session.Client = null,
+ geforce_webrtc: ?*geforce_webrtc.Session = null,
+ geforce_catalog: ?*geforce_catalog.Service = null,
+ provider: ?Provider = null,
curl_initialized: bool = false,
requested_title: [128]u8 = [_]u8{0} ** 128,
title_id: [128]u8 = [_]u8{0} ** 128,
+ title_name: [192]u8 = [_]u8{0} ** 192,
fn initializeMedia(self: *Release) bool {
const bootstrap_path = std.posix.getenv("GREENOVERCAST_H264_BOOTSTRAP_FILE");
@@ -80,7 +105,7 @@ pub const Release = struct {
.renderer = c.go_sdl_platform_renderer(self.platform),
.bootstrap_path = if (bootstrap_path) |path| path.ptr else null,
.max_width = 1280,
- .max_height = 720,
+ .max_height = 768,
.decoder_preference = decoder_preference,
};
self.video = c.go_video_pipeline_create(&config);
@@ -147,20 +172,102 @@ pub const Release = struct {
}
release.curl_initialized = true;
- release.auth = c.go_xbox_auth_create();
- if (release.auth == null) {
- std.debug.print("Xbox authentication configuration is incomplete\n", .{});
- release.close();
- return null;
+ return release;
+ }
+
+ pub fn selectProvider(self: *Release) Result {
+ if (self.provider != null) return .ok;
+ if (std.posix.getenv("GREENOVERCAST_SERVICE")) |value| {
+ const selected = parseProvider(value) orelse {
+ std.debug.print("Invalid GREENOVERCAST_SERVICE value\n", .{});
+ return .failed;
+ };
+ return self.initializeProvider(selected);
}
+ return self.pickProvider();
+ }
- release.cloud = c.go_cloud_session_create(release.auth, release.ui());
- if (release.cloud == null) {
- std.debug.print("Cloud session service could not be initialized\n", .{});
- release.close();
- return null;
+ fn pickProvider(self: *Release) Result {
+ while (!self.quitRequested()) {
+ const selected: Provider = switch (c.go_handheld_ui_pick_provider(self.ui())) {
+ c.GO_HANDHELD_UI_PROVIDER_XBOX => .xbox,
+ c.GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW => .geforce_now,
+ else => return .cancelled,
+ };
+ if (self.initializeProvider(selected) == .ok) return .ok;
}
- return release;
+ return .cancelled;
+ }
+
+ fn initializeProvider(self: *Release, selected: Provider) Result {
+ switch (selected) {
+ .xbox => {
+ self.auth = c.go_xbox_auth_create();
+ if (self.auth == null) {
+ std.debug.print("Xbox authentication configuration is incomplete\n", .{});
+ c.go_handheld_ui_show_error(self.ui(), "XBOX UNAVAILABLE", "SERVICE CONFIGURATION IS MISSING OR INVALID");
+ return .failed;
+ }
+ self.cloud = c.go_cloud_session_create(self.auth, self.ui());
+ if (self.cloud == null) {
+ c.go_xbox_auth_destroy(self.auth);
+ self.auth = null;
+ std.debug.print("Xbox cloud session service could not be initialized\n", .{});
+ c.go_handheld_ui_show_error(self.ui(), "XBOX UNAVAILABLE", "COULD NOT INITIALIZE THE SERVICE");
+ return .failed;
+ }
+ },
+ .geforce_now => {
+ self.geforce_auth = geforce_auth.Client.create(
+ std.heap.c_allocator,
+ self.ui() orelse return .failed,
+ ) catch |err| {
+ std.debug.print("GeForce NOW configuration: {s}\n", .{@errorName(err)});
+ const detail: [*:0]const u8 = switch (err) {
+ error.MissingConfig, error.FileNotFound => "SERVICE CONFIGURATION IS MISSING",
+ error.InvalidConfig, error.InvalidClientId => "SERVICE CONFIGURATION IS INVALID",
+ else => "COULD NOT INITIALIZE THE SERVICE",
+ };
+ c.go_handheld_ui_show_error(self.ui(), "GEFORCE NOW", detail);
+ return .failed;
+ };
+ self.geforce_cloud = geforce_session.Client.create(
+ std.heap.c_allocator,
+ self.geforce_auth.?,
+ self.ui() orelse return .failed,
+ ) catch |err| {
+ self.geforce_auth.?.destroy();
+ self.geforce_auth = null;
+ std.debug.print("GeForce NOW session service: {s}\n", .{@errorName(err)});
+ c.go_handheld_ui_show_error(self.ui(), "GEFORCE NOW", "COULD NOT INITIALIZE THE SERVICE");
+ return .failed;
+ };
+ },
+ }
+ self.provider = selected;
+ c.go_handheld_ui_set_provider(
+ self.ui(),
+ if (selected == .xbox)
+ c.GO_HANDHELD_UI_PROVIDER_XBOX
+ else
+ c.GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW,
+ );
+ return .ok;
+ }
+
+ pub fn switchProvider(self: *Release) Result {
+ const next: Provider = switch (self.provider orelse return .failed) {
+ .xbox => .geforce_now,
+ .geforce_now => .xbox,
+ };
+ self.destroyProvider();
+ if (self.initializeProvider(next) == .ok) return .ok;
+ return self.pickProvider();
+ }
+
+ pub fn returnToProviderPicker(self: *Release) Result {
+ self.destroyProvider();
+ return self.pickProvider();
}
fn ui(self: *const Release) ?*c.GoHandheldUi {
@@ -171,11 +278,22 @@ pub const Release = struct {
return c.go_sdl_platform_controller(self.platform);
}
+ pub fn quitRequested(self: *const Release) bool {
+ return stopRequested() or c.go_handheld_ui_quit_requested(self.ui()) != 0;
+ }
+
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);
}
pub fn loadCredentials(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ const loaded = self.geforce_auth.?.loadCredentials() catch {
+ std.debug.print("GeForce NOW credential store could not be read\n", .{});
+ return .failed;
+ };
+ return if (loaded) .ok else .missing_credentials;
+ }
const result = c.go_xbox_auth_load_credentials(self.auth);
if (result < 0) {
std.debug.print("Credential store could not be read\n", .{});
@@ -187,6 +305,35 @@ pub const Release = struct {
}
pub fn deviceSignIn(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ while (true) {
+ const signed_in = self.geforce_auth.?.signIn() catch |err| {
+ std.debug.print("GeForce NOW sign-in failed: {s}\n", .{@errorName(err)});
+ if (err == error.CredentialSaveFailed) return .failed;
+ const retry = if (err == error.CodeExpired)
+ c.go_handheld_ui_wait_for_retry(
+ self.ui(),
+ "CODE EXPIRED",
+ "REQUEST A NEW CODE",
+ )
+ else if (err == error.AccessDenied)
+ c.go_handheld_ui_wait_for_retry(
+ self.ui(),
+ "SIGN IN DENIED",
+ "REQUEST A NEW CODE",
+ )
+ else
+ c.go_handheld_ui_wait_for_retry(
+ self.ui(),
+ "SIGN IN UNAVAILABLE",
+ "CHECK YOUR CONNECTION",
+ );
+ if (retry == 0) return .cancelled;
+ continue;
+ };
+ return if (signed_in) .ok else .cancelled;
+ }
+ }
const result = c.go_xbox_auth_device_sign_in(self.auth, self.ui());
if (result == 0) return .cancelled;
if (result < 0) {
@@ -198,6 +345,14 @@ pub const Release = struct {
}
pub fn refreshAuth(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ self.drawLoading("SIGNING IN", "REFRESHING GEFORCE NOW SESSION", c.GO_HANDHELD_UI_ACTION_NONE);
+ return switch (self.geforce_auth.?.refresh()) {
+ .ok => .ok,
+ .reauth_required => .reauth_required,
+ .failed => .failed,
+ };
+ }
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)) {
@@ -215,6 +370,38 @@ pub const Release = struct {
}
pub fn loadCatalog(self: *Release) Result {
+ while (!self.quitRequested()) {
+ const result = self.loadCatalogOnce();
+ if (result != .failed) return result;
+ if (self.catalog) |catalog| catalog.destroy();
+ self.catalog = null;
+ if (self.geforce_catalog) |catalog| catalog.destroy();
+ self.geforce_catalog = null;
+ if (c.go_handheld_ui_wait_for_retry(
+ self.ui(),
+ "COULD NOT LOAD GAMES",
+ "RETRY OR CHOOSE ANOTHER SERVICE",
+ ) == 0) return if (self.quitRequested()) .cancelled else .change_provider;
+ }
+ return .cancelled;
+ }
+
+ fn loadCatalogOnce(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ self.geforce_catalog = geforce_catalog.Service.create(
+ std.heap.c_allocator,
+ self.geforce_auth.?,
+ self.ui() orelse return .failed,
+ ) catch return .failed;
+ self.drawLoading("LOADING LIBRARY", "CHECKING GEFORCE NOW GAMES", c.GO_HANDHELD_UI_ACTION_BACK);
+ self.geforce_catalog.?.load() catch |err| {
+ if (err == error.Cancelled) return .cancelled;
+ std.debug.print("GeForce NOW catalog failed: {s}\n", .{@errorName(err)});
+ return .failed;
+ };
+ debug("Playable titles: {d}\n", .{self.geforce_catalog.?.titleCount()});
+ return .ok;
+ }
const cloud = self.cloud orelse return .failed;
const ui_handle = self.ui() orelse return .failed;
const base_url_pointer = c.go_cloud_session_base_url(cloud);
@@ -245,6 +432,25 @@ pub const Release = struct {
}
pub fn pickTitle(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ const catalog = self.geforce_catalog orelse return .failed;
+ const requested = std.mem.sliceTo(&self.requested_title, 0);
+ const selection = catalog.pick(requested) catch return .failed;
+ @memset(&self.requested_title, 0);
+ const selected = switch (selection) {
+ .title => |value| value,
+ .cancelled => return .cancelled,
+ .change_provider => return .change_provider,
+ .sign_out => return .signed_out,
+ };
+ if (selected.id.len >= self.title_id.len or selected.name.len >= self.title_name.len)
+ return .failed;
+ @memset(&self.title_id, 0);
+ @memcpy(self.title_id[0..selected.id.len], selected.id);
+ @memset(&self.title_name, 0);
+ @memcpy(self.title_name[0..selected.name.len], selected.name);
+ return .ok;
+ }
if (self.catalog == null) return .failed;
const requested = std.mem.sliceTo(&self.requested_title, 0);
const selection = self.catalog.?.pick(requested) catch return .failed;
@@ -252,6 +458,7 @@ pub const Release = struct {
const selected = switch (selection) {
.title_id => |value| value,
.cancelled => return .cancelled,
+ .change_provider => return .change_provider,
.sign_out => return .signed_out,
};
if (selected.len >= self.title_id.len) return .failed;
@@ -261,6 +468,15 @@ pub const Release = struct {
}
pub fn signOut(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ self.drawLoading("SIGNING OUT", "REMOVING GEFORCE NOW CREDENTIALS", c.GO_HANDHELD_UI_ACTION_NONE);
+ self.geforce_auth.?.signOut() catch return .failed;
+ if (self.geforce_catalog) |catalog| catalog.destroy();
+ self.geforce_catalog = null;
+ @memset(&self.title_id, 0);
+ @memset(&self.title_name, 0);
+ return .ok;
+ }
self.drawLoading("SIGNING OUT", "REMOVING XBOX CREDENTIALS", c.GO_HANDHELD_UI_ACTION_NONE);
if (c.go_xbox_auth_sign_out(self.auth) != 0) {
std.debug.print("Xbox credentials could not be removed\n", .{});
@@ -276,6 +492,27 @@ pub const Release = struct {
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)});
+ if (self.provider == .geforce_now) {
+ const requested_width: u16 = @intCast(@min(
+ c.go_handheld_ui_stream_width(self.ui()),
+ std.math.maxInt(u16),
+ ));
+ const requested_height: u16 = @intCast(@min(
+ c.go_handheld_ui_stream_height(self.ui()),
+ std.math.maxInt(u16),
+ ));
+ self.geforce_cloud.?.start(
+ std.mem.sliceTo(&self.title_id, 0),
+ std.mem.sliceTo(&self.title_name, 0),
+ requested_width,
+ requested_height,
+ ) catch |err| {
+ if (err == error.Cancelled) return .cancelled;
+ std.debug.print("GeForce NOW session creation failed: {s}\n", .{@errorName(err)});
+ return .failed;
+ };
+ return .ok;
+ }
if (c.go_cloud_session_start_game(self.cloud, @ptrCast(&self.title_id)) < 0) {
std.debug.print("Session creation failed\n", .{});
return .failed;
@@ -284,6 +521,18 @@ pub const Release = struct {
}
pub fn waitReady(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ var playback = geforce_ads.Playback{ .renderer = @ptrCast(c.go_sdl_platform_renderer(self.platform)), .device = c.go_sdl_platform_audio_device(self.platform), .ui = @ptrCast(self.ui()) };
+ self.geforce_cloud.?.ad_handler = .{ .context = &playback, .run = geforce_ads.Playback.handle };
+ defer self.geforce_cloud.?.ad_handler = null;
+ self.geforce_cloud.?.waitUntilReady() catch |err| {
+ if (err == error.Cancelled) return .cancelled;
+ if (err == error.SessionEnded) return .session_ended;
+ std.debug.print("GeForce NOW provisioning failed: {s}\n", .{@errorName(err)});
+ return .failed;
+ };
+ 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,6 +543,7 @@ pub const Release = struct {
}
pub fn connect(self: *Release) Result {
+ if (self.provider == .geforce_now) return .ok;
debug("Connecting\n", .{});
if (c.go_cloud_session_connect(self.cloud) < 0) {
std.debug.print("Connect failed\n", .{});
@@ -303,6 +553,7 @@ pub const Release = struct {
}
pub fn waitProvisioned(self: *Release) Result {
+ if (self.provider == .geforce_now) return .ok;
debug("Waiting for Provisioned\n", .{});
if (c.go_cloud_session_wait_for_state(self.cloud, "Provisioned", 100) < 0) {
if (c.go_handheld_ui_cancelled(self.ui()) != 0) return .cancelled;
@@ -313,6 +564,27 @@ pub const Release = struct {
}
pub fn setupWebrtc(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ if (self.geforce_webrtc != null) return .failed;
+ const cloud = self.geforce_cloud.?.sessionInfo() orelse return .failed;
+ self.geforce_webrtc = geforce_webrtc.Session.create(
+ std.heap.c_allocator,
+ self.video orelse return .failed,
+ self.audio orelse return .failed,
+ self.controller() orelse return .failed,
+ self.ui() orelse return .failed,
+ cloud,
+ self.geforce_cloud.?.streamWidth(),
+ self.geforce_cloud.?.streamHeight(),
+ self.geforce_cloud.?.streamFramesPerSecond(),
+ ) catch return .failed;
+ self.geforce_webrtc.?.setup() catch |err| {
+ if (err == error.Cancelled) return .cancelled;
+ std.debug.print("GeForce NOW WebRTC setup failed: {s}\n", .{@errorName(err)});
+ return .failed;
+ };
+ return .ok;
+ }
if (self.webrtc != null) return .failed;
debug("Setting up WebRTC\n", .{});
const stream_width = c.go_handheld_ui_stream_width(self.ui());
@@ -336,6 +608,23 @@ pub const Release = struct {
}
pub fn waitConnected(self: *Release) Result {
+ if (self.provider == .geforce_now) {
+ const session = self.geforce_webrtc orelse return .failed;
+ const deadline = c.SDL_GetTicks() +% 60_000;
+ while (!session.isConnected() and !session.hasFailed() and !session.isClosed()) {
+ session.pump() catch |err| {
+ std.debug.print("GeForce NOW signaling failed: {s}\n", .{@errorName(err)});
+ return .failed;
+ };
+ const reached: i32 = @bitCast(c.SDL_GetTicks() -% deadline);
+ if (reached >= 0) break;
+ if (c.go_handheld_ui_wait(self.ui(), 16) != 0) return .cancelled;
+ }
+ if (session.isClosed()) return .session_ended;
+ if (!session.isConnected() or session.hasFailed()) return .failed;
+ session.requestKeyframe();
+ return .ok;
+ }
if (self.webrtc == null) return .failed;
debug("Waiting for WebRTC connection\n", .{});
var seconds: usize = 0;
@@ -369,7 +658,8 @@ pub const Release = struct {
}
pub fn stream(self: *Release) Result {
- if (self.webrtc == null) return .failed;
+ if (self.provider == .xbox and self.webrtc == null) return .failed;
+ if (self.provider == .geforce_now and self.geforce_webrtc == null) return .failed;
debug("Streaming (hold Select + Start for 1s to exit)\n", .{});
const stream_started = c.SDL_GetTicks();
var last_stats = stream_started;
@@ -381,7 +671,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 (self.provider == .xbox and c.go_cloud_session_start_keepalive(self.cloud) < 0) {
std.debug.print("Session keepalive worker failed to start\n", .{});
return .failed;
}
@@ -397,25 +687,51 @@ pub const Release = struct {
}
if (c.go_controller_input_exit_held(controller_input, 1000) != 0 or stopRequested())
return .cancelled;
- if (c.go_webrtc_session_failed(self.webrtc) != 0) {
- std.debug.print("WebRTC connection failed\n", .{});
- return .failed;
- }
- if (c.go_webrtc_session_closed(self.webrtc) != 0) {
- std.debug.print("Cloud game ended\n", .{});
- return .session_ended;
+ if (self.provider == .xbox) {
+ if (c.go_webrtc_session_failed(self.webrtc) != 0) {
+ std.debug.print("WebRTC connection failed\n", .{});
+ return .failed;
+ }
+ if (c.go_webrtc_session_closed(self.webrtc) != 0) {
+ std.debug.print("Cloud game ended\n", .{});
+ return .session_ended;
+ }
+ } else {
+ const session = self.geforce_webrtc.?;
+ session.pump() catch |err| {
+ std.debug.print("GeForce NOW stream failed: {s}\n", .{@errorName(err)});
+ return .failed;
+ };
+ if (session.hasFailed()) return .failed;
+ if (session.isClosed()) return .session_ended;
}
if (c.go_video_pipeline_failed(self.video) != 0)
return .failed;
- c.go_webrtc_session_send_gamepad(self.webrtc);
- c.go_video_pipeline_render(self.video);
- c.go_webrtc_session_request_video_bitrate(self.webrtc, 2_000_000);
+ if (self.provider == .xbox)
+ c.go_webrtc_session_send_gamepad(self.webrtc)
+ else
+ self.geforce_webrtc.?.sendInput() catch return .failed;
+ if (c.go_video_pipeline_render(self.video) != 0) {
+ if (self.provider == .geforce_now) {
+ const pointer = self.geforce_webrtc.?.pointer;
+ const video = c.go_video_pipeline_stats(self.video);
+ c.go_handheld_ui_draw_stream_controls(self.ui(), @intFromBool(pointer.enabled), pointer.x, pointer.y, video.source_width, video.source_height, @intFromBool(c.SDL_GetTicks() -% stream_started < 8000));
+ }
+ c.SDL_RenderPresent(c.go_sdl_platform_renderer(self.platform));
+ }
+ if (self.provider == .xbox)
+ c.go_webrtc_session_request_video_bitrate(self.webrtc, 2_000_000)
+ else
+ self.geforce_webrtc.?.requestBitrate();
const now = c.SDL_GetTicks();
if (c.go_video_pipeline_needs_keyframe(self.video) != 0 and
now -% last_keyframe_request >= 500)
{
- c.go_webrtc_session_request_keyframe(self.webrtc);
+ if (self.provider == .xbox)
+ c.go_webrtc_session_request_keyframe(self.webrtc)
+ else
+ self.geforce_webrtc.?.requestKeyframe();
last_keyframe_request = now;
}
if (now -% last_stats >= 1000) {
@@ -441,7 +757,10 @@ 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 = if (self.provider == .xbox)
+ c.go_cloud_session_stats(self.cloud)
+ else
+ std.mem.zeroes(c.GoCloudSessionStats);
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} " ++
@@ -500,18 +819,30 @@ pub const Release = struct {
}
fn closeSession(self: *Release) void {
- c.go_cloud_session_stop_keepalive(self.cloud);
- c.go_webrtc_session_destroy(self.webrtc);
- self.webrtc = null;
+ if (self.provider == .xbox) {
+ c.go_cloud_session_stop_keepalive(self.cloud);
+ c.go_webrtc_session_destroy(self.webrtc);
+ self.webrtc = null;
+ } else if (self.provider == .geforce_now) {
+ if (self.geforce_webrtc) |session| session.destroy();
+ self.geforce_webrtc = null;
+ }
c.go_video_pipeline_stop(self.video);
c.go_audio_pipeline_stop(self.audio);
- c.go_cloud_session_end(self.cloud);
+ if (self.provider == .xbox) {
+ c.go_cloud_session_end(self.cloud);
+ } else if (self.provider == .geforce_now) {
+ self.geforce_cloud.?.stop() catch |err|
+ std.debug.print("GeForce NOW session cleanup failed: {s}\n", .{@errorName(err)});
+ }
}
pub fn resetSession(self: *Release) Result {
self.closeSession();
self.destroyMedia();
+ self.requested_title = self.title_id;
@memset(&self.title_id, 0);
+ @memset(&self.title_name, 0);
if (stopRequested()) return .cancelled;
if (!self.initializeMedia()) return .failed;
return .ok;
@@ -520,13 +851,30 @@ pub const Release = struct {
pub fn close(self: *Release) void {
debug("Cleanup\n", .{});
self.closeSession();
- if (self.catalog) |catalog| catalog.destroy();
- c.go_cloud_session_destroy(self.cloud);
- c.go_xbox_auth_destroy(self.auth);
+ self.destroyProvider();
self.destroyMedia();
c.go_sdl_platform_destroy(self.platform);
if (self.curl_initialized) c.curl_global_cleanup();
std.crypto.secureZero(u8, std.mem.asBytes(self));
std.heap.c_allocator.destroy(self);
}
+
+ fn destroyProvider(self: *Release) void {
+ if (self.catalog) |catalog| catalog.destroy();
+ self.catalog = null;
+ if (self.geforce_catalog) |catalog| catalog.destroy();
+ self.geforce_catalog = null;
+ if (self.geforce_cloud) |cloud| cloud.destroy();
+ self.geforce_cloud = null;
+ if (self.geforce_auth) |auth| auth.destroy();
+ self.geforce_auth = null;
+ c.go_cloud_session_destroy(self.cloud);
+ self.cloud = null;
+ c.go_xbox_auth_destroy(self.auth);
+ self.auth = null;
+ self.provider = null;
+ @memset(&self.requested_title, 0);
+ @memset(&self.title_id, 0);
+ @memset(&self.title_name, 0);
+ }
};
diff --git a/src/app/state.zig b/src/app/state.zig
index 96ea55f..e702bad 100644
--- a/src/app/state.zig
+++ b/src/app/state.zig
@@ -32,9 +32,14 @@ pub const Event = enum {
rtc_connected,
session_ended,
session_failed,
+ provider_changed,
user_quit,
};
+pub fn sessionCancellation(current: State, quit_requested: bool) Event {
+ return if (current == .streaming or quit_requested) .user_quit else .session_ended;
+}
+
pub fn transition(current: State, event: Event) State {
if (event == .user_quit)
return .shutdown;
@@ -60,11 +65,13 @@ pub fn transition(current: State, event: Event) State {
},
.loading_catalog => switch (event) {
.catalog_loaded => .catalog,
+ .provider_changed => .cold_start,
else => .loading_catalog,
},
.catalog => switch (event) {
.user_select_title => .provisioning,
.auth_sign_out => .signed_out,
+ .provider_changed => .cold_start,
else => .catalog,
},
.provisioning => switch (event) {
@@ -124,6 +131,20 @@ test "sign out returns the catalog to authentication" {
try std.testing.expectEqual(State.device_code_pending, state);
}
+test "changing provider starts a fresh authentication flow" {
+ try std.testing.expectEqual(State.cold_start, transition(.catalog, .provider_changed));
+}
+
+test "catalog failure can return to service selection and reuse credentials" {
+ var state = transition(.loading_catalog, .provider_changed);
+ try std.testing.expectEqual(State.cold_start, state);
+ state = transition(state, .auth_tokens_found);
+ try std.testing.expectEqual(State.authenticating, state);
+ state = transition(state, .auth_success);
+ state = transition(state, .catalog_loaded);
+ try std.testing.expectEqual(State.catalog, state);
+}
+
test "catalog selection reaches streaming" {
var state = transition(.catalog, .user_select_title);
try std.testing.expectEqual(State.provisioning, state);
@@ -143,6 +164,14 @@ test "ended and failed sessions return to the catalog" {
try std.testing.expectEqual(State.catalog, transition(.provisioning, .session_failed));
}
+test "cancelling game startup returns to the catalog while quit remains explicit" {
+ for ([_]State{ .provisioning, .signaling, .connecting }) |state| {
+ try std.testing.expectEqual(State.catalog, transition(state, sessionCancellation(state, false)));
+ try std.testing.expectEqual(State.shutdown, transition(state, sessionCancellation(state, true)));
+ }
+ try std.testing.expectEqual(Event.user_quit, sessionCancellation(.streaming, false));
+}
+
test "shutdown is terminal and reachable from every active state" {
const states = [_]State{
.cold_start,
diff --git a/src/auth/xbox_auth.zig b/src/auth/xbox_auth.zig
index ffa1619..24982c6 100644
--- a/src/auth/xbox_auth.zig
+++ b/src/auth/xbox_auth.zig
@@ -20,11 +20,6 @@ const auth_failed: c_int = -1;
const auth_ok: c_int = 0;
const auth_reauth_required: c_int = 1;
-const FormField = struct {
- key: []const u8,
- value: []const u8,
-};
-
const Auth = struct {
client_id: [128]u8 = [_]u8{0} ** 128,
gssv_token: [8192]u8 = [_]u8{0} ** 8192,
@@ -81,23 +76,6 @@ fn loadClientId(auth: *Auth) !void {
try copyString(&auth.client_id, xbox_web_client_id);
}
-fn buildForm(output: []u8, fields: []const FormField) ![:0]u8 {
- if (output.len == 0) return error.NoSpaceLeft;
- var used: usize = 0;
- for (fields, 0..) |field, index| {
- const prefix = try std.fmt.bufPrint(
- output[used..],
- "{s}{s}=",
- .{ if (index == 0) "" else "&", field.key },
- );
- used += prefix.len;
- used += form.encode(field.value, output[used..]) catch return error.NoSpaceLeft;
- }
- if (used >= output.len) return error.NoSpaceLeft;
- output[used] = 0;
- return output[0..used :0];
-}
-
fn responseData(response: [*c]c.GoHttpResponse) ?[]const u8 {
if (response == null or response.*.data == null) return null;
return response.*.data[0..response.*.len];
@@ -164,10 +142,10 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool {
var headers = [_][*c]const u8{"Content-Type: application/x-www-form-urlencoded"};
while (true) {
var body_buffer: [4096]u8 = undefined;
- const body = try buildForm(&body_buffer, &.{
- .{ .key = "client_id", .value = cString(&auth.client_id) },
- .{ .key = "scope", .value = oauth_scope },
- });
+ const body = try form.build(&.{
+ .{ .name = "client_id", .value = cString(&auth.client_id) },
+ .{ .name = "scope", .value = oauth_scope },
+ }, &body_buffer);
c.go_handheld_ui_draw_loading(ui, "XBOX SIGN IN", "REQUESTING A DEVICE CODE", c.GO_HANDHELD_UI_ACTION_BACK);
var response = c.go_http_request(
"POST",
@@ -189,7 +167,7 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool {
const user_code = jsonString(data, "user_code", &user_code_buffer) catch null;
const device_code = jsonString(data, "device_code", &device_code_buffer) catch null;
var interval = jsonUnsigned(data, "interval", 5);
- var expires_in = jsonUnsigned(data, "expires_in", 900);
+ const expires_in = jsonUnsigned(data, "expires_in", 900);
c.go_http_response_destroy(response);
if (user_code == null or device_code == null) {
std.crypto.secureZero(u8, &device_code_buffer);
@@ -198,15 +176,15 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool {
continue;
}
defer std.crypto.secureZero(u8, &device_code_buffer);
- interval = std.math.clamp(interval, 1, 30);
- if (expires_in < interval) expires_in = interval;
+ interval = @max(interval, 1);
const started = c.SDL_GetTicks();
- var next_poll = started +% interval * 1000;
+ var last_poll = started;
var restart = false;
- while ((c.SDL_GetTicks() -% started) / 1000 < expires_in) {
+ while (true) {
const now = c.SDL_GetTicks();
const elapsed = (now -% started) / 1000;
+ if (elapsed >= expires_in) break;
c.go_handheld_ui_draw_device_code(
ui,
@ptrCast(&user_code_buffer),
@@ -214,17 +192,16 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool {
expires_in - elapsed,
);
if (c.go_handheld_ui_sign_in_action(ui) < 0) return false;
- const until_poll: i32 = @bitCast(now -% next_poll);
- if (until_poll < 0) {
+ if ((now -% last_poll) / 1000 < interval) {
c.SDL_Delay(16);
continue;
}
- const token_body = try buildForm(&body_buffer, &.{
- .{ .key = "grant_type", .value = "urn:ietf:params:oauth:grant-type:device_code" },
- .{ .key = "client_id", .value = cString(&auth.client_id) },
- .{ .key = "device_code", .value = device_code.? },
- });
+ const token_body = try form.build(&.{
+ .{ .name = "grant_type", .value = "urn:ietf:params:oauth:grant-type:device_code" },
+ .{ .name = "client_id", .value = cString(&auth.client_id) },
+ .{ .name = "device_code", .value = device_code.? },
+ }, &body_buffer);
response = c.go_http_request(
"POST",
oauth_token_url,
@@ -232,7 +209,7 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool {
@ptrCast(&headers),
headers.len,
);
- next_poll = c.SDL_GetTicks() +% interval * 1000;
+ last_poll = c.SDL_GetTicks();
const token_data = responseData(response) orelse {
c.go_http_response_destroy(response);
continue;
@@ -259,7 +236,7 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool {
const message = jsonString(token_data, "error", &error_buffer) catch null;
if (message) |value| {
if (std.mem.eql(u8, value, "slow_down"))
- interval = @min(interval + 5, 30)
+ interval +|= 5
else if (!std.mem.eql(u8, value, "authorization_pending"))
restart = true;
}
@@ -290,12 +267,12 @@ fn refresh(auth: *Auth) !c_int {
"x-gssv-client: XboxComBrowser",
};
- const refresh_body = try buildForm(&body_buffer, &.{
- .{ .key = "client_id", .value = cString(&auth.client_id) },
- .{ .key = "grant_type", .value = "refresh_token" },
- .{ .key = "refresh_token", .value = cString(&auth.refresh_token) },
- .{ .key = "scope", .value = oauth_scope },
- });
+ const refresh_body = try form.build(&.{
+ .{ .name = "client_id", .value = cString(&auth.client_id) },
+ .{ .name = "grant_type", .value = "refresh_token" },
+ .{ .name = "refresh_token", .value = cString(&auth.refresh_token) },
+ .{ .name = "scope", .value = oauth_scope },
+ }, &body_buffer);
var response = c.go_http_request(
"POST",
oauth_token_url,
@@ -399,12 +376,12 @@ fn refresh(auth: *Auth) !c_int {
c.go_http_response_destroy(response);
debug("gsToken obtained\n", .{});
- const passport_body = try buildForm(&body_buffer, &.{
- .{ .key = "client_id", .value = cString(&auth.client_id) },
- .{ .key = "grant_type", .value = "refresh_token" },
- .{ .key = "refresh_token", .value = cString(&auth.refresh_token) },
- .{ .key = "scope", .value = passport_scope },
- });
+ const passport_body = try form.build(&.{
+ .{ .name = "client_id", .value = cString(&auth.client_id) },
+ .{ .name = "grant_type", .value = "refresh_token" },
+ .{ .name = "refresh_token", .value = cString(&auth.refresh_token) },
+ .{ .name = "scope", .value = passport_scope },
+ }, &body_buffer);
response = c.go_http_request(
"POST",
"https://login.live.com/oauth20_token.srf",
diff --git a/src/catalog/catalog_parser.zig b/src/catalog/catalog_parser.zig
index 5f94994..1d4216e 100644
--- a/src/catalog/catalog_parser.zig
+++ b/src/catalog/catalog_parser.zig
@@ -112,7 +112,7 @@ pub fn writeCString(destination: []u8, text: []const u8) bool {
return true;
}
-fn writeDisplayCString(destination: []u8, text: []const u8) bool {
+pub fn writeDisplayCString(destination: []u8, text: []const u8) bool {
@memset(destination, 0);
var source_index: usize = 0;
var destination_index: usize = 0;
diff --git a/src/catalog/service.zig b/src/catalog/service.zig
index 184b6a9..b6fb890 100644
--- a/src/catalog/service.zig
+++ b/src/catalog/service.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const parser = @import("catalog_parser.zig");
+const parser = @import("catalog_parser");
const c = @cImport({
@cInclude("cloud_session.h");
@@ -17,6 +17,7 @@ pub const LoadResult = enum {
pub const PickResult = union(enum) {
title_id: []const u8,
cancelled,
+ change_provider,
sign_out,
};
@@ -115,6 +116,7 @@ pub const Service = struct {
@intCast(self.count),
@ptrCast(&requested_buffer),
);
+ if (selected == c.GO_HANDHELD_UI_PICK_CHANGE_PROVIDER) return .change_provider;
if (selected == c.GO_HANDHELD_UI_PICK_SIGN_OUT) return .sign_out;
if (selected == c.GO_HANDHELD_UI_PICK_CANCELLED) return .cancelled;
if (selected < 0) return error.InvalidSelection;
diff --git a/src/input/controller.h b/src/input/controller.h
index a43b2d4..0efd535 100644
--- a/src/input/controller.h
+++ b/src/input/controller.h
@@ -16,17 +16,45 @@ typedef enum {
GO_FACE_BUTTON_MODE_SWAPPED = 1,
} GoFaceButtonMode;
+enum {
+ GO_CONTROLLER_BUTTON_A = 1u << 0,
+ GO_CONTROLLER_BUTTON_B = 1u << 1,
+ GO_CONTROLLER_BUTTON_X = 1u << 2,
+ GO_CONTROLLER_BUTTON_Y = 1u << 3,
+ GO_CONTROLLER_BUTTON_LEFT_SHOULDER = 1u << 4,
+ GO_CONTROLLER_BUTTON_RIGHT_SHOULDER = 1u << 5,
+ GO_CONTROLLER_BUTTON_BACK = 1u << 6,
+ GO_CONTROLLER_BUTTON_START = 1u << 7,
+ GO_CONTROLLER_BUTTON_DPAD_UP = 1u << 8,
+ GO_CONTROLLER_BUTTON_DPAD_DOWN = 1u << 9,
+ GO_CONTROLLER_BUTTON_DPAD_LEFT = 1u << 10,
+ GO_CONTROLLER_BUTTON_DPAD_RIGHT = 1u << 11,
+ GO_CONTROLLER_BUTTON_LEFT_STICK = 1u << 12,
+ GO_CONTROLLER_BUTTON_RIGHT_STICK = 1u << 13,
+ GO_CONTROLLER_BUTTON_GUIDE = 1u << 14,
+};
+
+typedef struct {
+ uint32_t buttons;
+ int16_t left_x;
+ int16_t left_y;
+ int16_t right_x;
+ int16_t right_y;
+ uint16_t left_trigger;
+ uint16_t right_trigger;
+} GoControllerState;
+
GoControllerInput* go_controller_input_create(void);
void go_controller_input_destroy(GoControllerInput* input);
void go_controller_input_handle_event(GoControllerInput* input, const SDL_Event* event);
-int go_controller_input_event_is_active(const GoControllerInput* input,
- const SDL_Event* event);
+int go_controller_input_event_is_active(const GoControllerInput* input, const SDL_Event* event);
void go_controller_input_set_face_button_mode(GoControllerInput* input, GoFaceButtonMode mode);
SDL_GameControllerButton go_controller_input_map_button(const GoControllerInput* input,
Uint8 physical_button);
int go_controller_input_button_pressed(const GoControllerInput* input,
SDL_GameControllerButton semantic_button);
Sint16 go_controller_input_axis(const GoControllerInput* input, SDL_GameControllerAxis axis);
+int go_controller_input_sample(GoControllerInput* input, GoControllerState* state);
size_t go_controller_input_encode_metadata(GoControllerInput* input, uint8_t* output,
size_t capacity);
size_t go_controller_input_encode(GoControllerInput* input, uint8_t* output, size_t capacity);
diff --git a/src/input/controller.zig b/src/input/controller.zig
index b75896f..1e8cd4c 100644
--- a/src/input/controller.zig
+++ b/src/input/controller.zig
@@ -223,6 +223,51 @@ pub export fn go_controller_input_axis(
return axis(input orelse return 0, controller_axis);
}
+pub export fn go_controller_input_sample(input: ?*Input, state: ?*c.GoControllerState) c_int {
+ const handle = input orelse return 0;
+ const output = state orelse return 0;
+ if (handle.controller == null) return 0;
+
+ var buttons: u32 = 0;
+ if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_A)) buttons |= c.GO_CONTROLLER_BUTTON_A;
+ if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_B)) buttons |= c.GO_CONTROLLER_BUTTON_B;
+ if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_X)) buttons |= c.GO_CONTROLLER_BUTTON_X;
+ if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_Y)) buttons |= c.GO_CONTROLLER_BUTTON_Y;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) buttons |= c.GO_CONTROLLER_BUTTON_LEFT_SHOULDER;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER)) buttons |= c.GO_CONTROLLER_BUTTON_RIGHT_SHOULDER;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_BACK)) buttons |= c.GO_CONTROLLER_BUTTON_BACK;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_START)) buttons |= c.GO_CONTROLLER_BUTTON_START;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_UP)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_UP;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_DOWN)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_DOWN;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_LEFT)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_LEFT;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_RIGHT;
+
+ const stick_buttons = handle.guide_chord.update(
+ button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSTICK),
+ button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSTICK),
+ );
+ const guide_chord_active = stick_buttons.left and stick_buttons.right;
+ if (button(handle, c.SDL_CONTROLLER_BUTTON_GUIDE) or guide_chord_active)
+ buttons |= c.GO_CONTROLLER_BUTTON_GUIDE;
+ if (!guide_chord_active) {
+ if (stick_buttons.left) buttons |= c.GO_CONTROLLER_BUTTON_LEFT_STICK;
+ if (stick_buttons.right) buttons |= c.GO_CONTROLLER_BUTTON_RIGHT_STICK;
+ }
+
+ const raw_left_y = axis(handle, c.SDL_CONTROLLER_AXIS_LEFTY);
+ const raw_right_y = axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTY);
+ output.* = .{
+ .buttons = buttons,
+ .left_x = axis(handle, c.SDL_CONTROLLER_AXIS_LEFTX),
+ .left_y = if (raw_left_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_left_y,
+ .right_x = axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTX),
+ .right_y = if (raw_right_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_right_y,
+ .left_trigger = trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERLEFT),
+ .right_trigger = trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERRIGHT),
+ };
+ return 1;
+}
+
pub export fn go_controller_input_encode_metadata(
input: ?*Input,
output: ?[*]u8,
@@ -245,44 +290,21 @@ pub export fn go_controller_input_encode(
capacity: usize,
) usize {
const handle = input orelse return 0;
- if (handle.controller == null) return 0;
const bytes = output orelse return 0;
if (capacity < 38) return 0;
-
- var source_buttons: u32 = 0;
- if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_A)) source_buttons |= wire.SourceButton.a;
- if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_B)) source_buttons |= wire.SourceButton.b;
- if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_X)) source_buttons |= wire.SourceButton.x;
- if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_Y)) source_buttons |= wire.SourceButton.y;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) source_buttons |= wire.SourceButton.left_shoulder;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER)) source_buttons |= wire.SourceButton.right_shoulder;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_BACK)) source_buttons |= wire.SourceButton.back;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_START)) source_buttons |= wire.SourceButton.start;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_UP)) source_buttons |= wire.SourceButton.dpad_up;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_DOWN)) source_buttons |= wire.SourceButton.dpad_down;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_LEFT)) source_buttons |= wire.SourceButton.dpad_left;
- if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) source_buttons |= wire.SourceButton.dpad_right;
- const left_stick = button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSTICK);
- const right_stick = button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSTICK);
- const stick_buttons = handle.guide_chord.update(left_stick, right_stick);
- if (stick_buttons.left) source_buttons |= wire.SourceButton.left_stick;
- if (stick_buttons.right) source_buttons |= wire.SourceButton.right_stick;
-
- const raw_left_y = axis(handle, c.SDL_CONTROLLER_AXIS_LEFTY);
- const raw_right_y = axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTY);
- const left_y = if (raw_left_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_left_y;
- const right_y = if (raw_right_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_right_y;
+ var state: c.GoControllerState = undefined;
+ if (go_controller_input_sample(handle, &state) == 0) return 0;
wire.encodeGamepadRaw(
bytes[0..wire.PACKET_SIZE],
handle.sequence,
0.0,
- wire.buttonMask(source_buttons),
- axis(handle, c.SDL_CONTROLLER_AXIS_LEFTX),
- left_y,
- axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTX),
- right_y,
- trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERLEFT),
- trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERRIGHT),
+ wire.buttonMask(state.buttons),
+ state.left_x,
+ state.left_y,
+ state.right_x,
+ state.right_y,
+ state.left_trigger,
+ state.right_trigger,
);
handle.sequence +%= 1;
return wire.PACKET_SIZE;
diff --git a/src/input/wire_encoder.zig b/src/input/wire_encoder.zig
index cd6de6e..73dee9c 100644
--- a/src/input/wire_encoder.zig
+++ b/src/input/wire_encoder.zig
@@ -33,6 +33,7 @@ pub const SourceButton = struct {
pub const dpad_right: u32 = 1 << 11;
pub const left_stick: u32 = 1 << 12;
pub const right_stick: u32 = 1 << 13;
+ pub const guide: u32 = 1 << 14;
};
pub const GamepadState = struct {
@@ -64,18 +65,14 @@ pub fn buttonMask(source: u32) u16 {
.{ SourceButton.dpad_down, Button.dpad_down },
.{ SourceButton.dpad_left, Button.dpad_left },
.{ SourceButton.dpad_right, Button.dpad_right },
+ .{ SourceButton.guide, Button.nexus },
+ .{ SourceButton.left_stick, Button.left_stick },
+ .{ SourceButton.right_stick, Button.right_stick },
};
for (mappings) |mapping| {
if (source & mapping[0] != 0) mask |= mapping[1];
}
- const stick_chord = SourceButton.left_stick | SourceButton.right_stick;
- if (source & stick_chord == stick_chord) {
- mask |= Button.nexus;
- } else {
- if (source & SourceButton.left_stick != 0) mask |= Button.left_stick;
- if (source & SourceButton.right_stick != 0) mask |= Button.right_stick;
- }
return mask;
}
@@ -153,10 +150,20 @@ test "every physical control maps to the xCloud mask" {
for (cases) |case| try std.testing.expectEqual(case[1], buttonMask(case[0]));
}
-test "L3 and R3 together produce only Nexus" {
- const chord = SourceButton.left_stick | SourceButton.right_stick;
- try std.testing.expectEqual(Button.nexus, buttonMask(chord));
- try std.testing.expectEqual(Button.a | Button.nexus, buttonMask(SourceButton.a | chord));
+test "semantic guide produces Nexus" {
+ try std.testing.expectEqual(Button.nexus, buttonMask(SourceButton.guide));
+ try std.testing.expectEqual(Button.a | Button.nexus, buttonMask(SourceButton.a | SourceButton.guide));
+}
+
+test "guide preserves independent stick clicks" {
+ const cases = [_]struct { u32, u16 }{
+ .{ SourceButton.left_stick, Button.left_stick },
+ .{ SourceButton.right_stick, Button.right_stick },
+ .{ SourceButton.left_stick | SourceButton.right_stick, Button.left_stick | Button.right_stick },
+ };
+ for (cases) |pair| {
+ try std.testing.expectEqual(Button.nexus | pair[1], buttonMask(SourceButton.guide | pair[0]));
+ }
}
test "button A encodes correctly" {
diff --git a/src/main.zig b/src/main.zig
index 12fa0da..6c5dfd8 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -38,7 +38,7 @@ fn stage(result: release_mod.Result) enum { ok, cancelled, failed } {
};
}
-const CatalogFlow = enum { ok, cancelled, failed };
+const CatalogFlow = enum { ok, cancelled, change_provider, failed };
fn openCatalog(
release: *release_mod.Release,
@@ -72,10 +72,11 @@ fn openCatalog(
}
break;
}
- switch (stage(release.loadCatalog())) {
+ switch (release.loadCatalog()) {
.ok => if (!move(state, .catalog_loaded, .catalog)) return .failed,
.cancelled => return .cancelled,
- .failed => return .failed,
+ .change_provider => return .change_provider,
+ else => return .failed,
}
return .ok;
}
@@ -111,52 +112,84 @@ pub fn main() u8 {
const release = release_mod.Release.open(requested_title) orelse return 1;
defer release.close();
- var state = state_mod.State.cold_start;
- var needs_sign_in = false;
- const credentials = release.loadCredentials();
- if (credentials == .missing_credentials) {
- if (!move(&state, .auth_no_tokens, .signed_out)) return 1;
- needs_sign_in = true;
- } else if (credentials == .ok) {
- if (!move(&state, .auth_tokens_found, .authenticating)) return 1;
- } else {
- return finish(&state, 1);
- }
-
- switch (openCatalog(release, &state, needs_sign_in)) {
+ switch (stage(release.selectProvider())) {
.ok => {},
- .cancelled => return finish(&state, 0),
- .failed => return finish(&state, 1),
+ .cancelled => return 0,
+ .failed => return 1,
}
- while (true) {
- const selection = release.pickTitle();
- if (selection == .signed_out) {
- if (release.signOut() != .ok or !move(&state, .auth_sign_out, .signed_out))
- return finish(&state, 1);
- switch (openCatalog(release, &state, true)) {
- .ok => continue,
- .cancelled => return finish(&state, 0),
- .failed => return finish(&state, 1),
- }
- }
- switch (stage(selection)) {
- .ok => if (!move(&state, .user_select_title, .provisioning)) return 1,
- .cancelled => return finish(&state, 0),
- .failed => return finish(&state, 1),
- }
- const outcome = runSelectedSession(release, &state);
- if (outcome == .cancelled) return finish(&state, 0);
+ var state = state_mod.State.cold_start;
+ provider_loop: while (true) {
+ var needs_sign_in = false;
+ const credentials = release.loadCredentials();
+ if (credentials == .missing_credentials) {
+ if (!move(&state, .auth_no_tokens, .signed_out)) return 1;
+ needs_sign_in = true;
+ } else if (credentials == .ok) {
+ if (!move(&state, .auth_tokens_found, .authenticating)) return 1;
+ } else {
+ return finish(&state, 1);
+ }
- const return_event: state_mod.Event = if (outcome == .session_ended or outcome == .ok)
- .session_ended
- else
- .session_failed;
- switch (stage(release.resetSession())) {
+ switch (openCatalog(release, &state, needs_sign_in)) {
.ok => {},
+ .change_provider => {
+ if (!move(&state, .provider_changed, .cold_start)) return 1;
+ switch (stage(release.returnToProviderPicker())) {
+ .ok => continue :provider_loop,
+ .cancelled => return finish(&state, 0),
+ .failed => return finish(&state, 1),
+ }
+ },
.cancelled => return finish(&state, 0),
.failed => return finish(&state, 1),
}
- if (!move(&state, return_event, .catalog)) return 1;
+ catalog_loop: while (true) {
+ const selection = release.pickTitle();
+ if (selection == .change_provider) {
+ if (!move(&state, .provider_changed, .cold_start)) return 1;
+ switch (stage(release.switchProvider())) {
+ .ok => continue :provider_loop,
+ .cancelled => return finish(&state, 0),
+ .failed => return finish(&state, 1),
+ }
+ }
+ if (selection == .signed_out) {
+ if (release.signOut() != .ok or !move(&state, .auth_sign_out, .signed_out))
+ return finish(&state, 1);
+ switch (openCatalog(release, &state, true)) {
+ .ok => continue :catalog_loop,
+ .change_provider => {
+ if (!move(&state, .provider_changed, .cold_start)) return 1;
+ switch (stage(release.returnToProviderPicker())) {
+ .ok => continue :provider_loop,
+ .cancelled => return finish(&state, 0),
+ .failed => return finish(&state, 1),
+ }
+ },
+ .cancelled => return finish(&state, 0),
+ .failed => return finish(&state, 1),
+ }
+ }
+ switch (stage(selection)) {
+ .ok => if (!move(&state, .user_select_title, .provisioning)) return 1,
+ .cancelled => return finish(&state, 0),
+ .failed => return finish(&state, 1),
+ }
+
+ const outcome = runSelectedSession(release, &state);
+ const return_event: state_mod.Event = switch (outcome) {
+ .cancelled => state_mod.sessionCancellation(state, release.quitRequested()),
+ .session_ended, .ok => .session_ended,
+ else => .session_failed,
+ };
+ if (return_event == .user_quit) return finish(&state, 0);
+ switch (stage(release.resetSession())) {
+ .ok => {},
+ .cancelled => return finish(&state, 0),
+ .failed => return finish(&state, 1),
+ }
+ if (!move(&state, return_event, .catalog)) return 1;
+ }
}
}
diff --git a/src/media/audio/audio_pipeline.h b/src/media/audio/audio_pipeline.h
index 42fb68d..233b21e 100644
--- a/src/media/audio/audio_pipeline.h
+++ b/src/media/audio/audio_pipeline.h
@@ -24,6 +24,7 @@ typedef struct {
GoAudioPipeline* go_audio_pipeline_create(SDL_AudioDeviceID device);
int go_audio_pipeline_start(GoAudioPipeline* pipeline);
void go_audio_pipeline_stop(GoAudioPipeline* pipeline);
+int go_audio_pipeline_set_payload_type(GoAudioPipeline* pipeline, int payload_type);
void go_audio_pipeline_push_rtp(GoAudioPipeline* pipeline, const uint8_t* packet, size_t length);
GoAudioStats go_audio_pipeline_stats(GoAudioPipeline* pipeline);
void go_audio_pipeline_destroy(GoAudioPipeline* pipeline);
diff --git a/src/media/audio/audio_pipeline.zig b/src/media/audio/audio_pipeline.zig
index f76fcaf..8d4d1e3 100644
--- a/src/media/audio/audio_pipeline.zig
+++ b/src/media/audio/audio_pipeline.zig
@@ -6,7 +6,7 @@ const c = @cImport({
@cInclude("opus/opus.h");
});
-const payload_type = 111;
+const default_payload_type = 111;
const queue_capacity = 32;
const target_pending_packets = 2;
const packet_capacity = 2048;
@@ -31,6 +31,7 @@ const Pipeline = struct {
thread: ?std.Thread = null,
stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
accepting_packets: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ payload_type: std.atomic.Value(u8) = std.atomic.Value(u8).init(default_payload_type),
rtp_packets: std.atomic.Value(c_int) = std.atomic.Value(c_int).init(0),
decoded_packets: std.atomic.Value(c_int) = std.atomic.Value(c_int).init(0),
dropped_packets: std.atomic.Value(c_int) = std.atomic.Value(c_int).init(0),
@@ -109,8 +110,10 @@ pub export fn go_audio_pipeline_create(device: c.SDL_AudioDeviceID) ?*Pipeline {
c.opus_decoder_destroy(decoder);
return null;
}
- errdefer c.opus_decoder_destroy(decoder);
- const pipeline = std.heap.c_allocator.create(Pipeline) catch return null;
+ const pipeline = std.heap.c_allocator.create(Pipeline) catch {
+ c.opus_decoder_destroy(decoder);
+ return null;
+ };
pipeline.* = .{ .device = device, .decoder = decoder };
return pipeline;
}
@@ -149,6 +152,17 @@ pub export fn go_audio_pipeline_stop(pipeline_pointer: ?*Pipeline) void {
pipeline.mutex.unlock();
}
+pub export fn go_audio_pipeline_set_payload_type(
+ pipeline_pointer: ?*Pipeline,
+ payload_type: c_int,
+) c_int {
+ const pipeline = pipeline_pointer orelse return -1;
+ if (payload_type < 0 or payload_type > 127 or pipeline.rtp_packets.load(.acquire) != 0)
+ return -1;
+ pipeline.payload_type.store(@intCast(payload_type), .release);
+ return 0;
+}
+
pub export fn go_audio_pipeline_push_rtp(
pipeline_pointer: ?*Pipeline,
packet_pointer: ?[*]const u8,
@@ -158,7 +172,8 @@ pub export fn go_audio_pipeline_push_rtp(
const packet = packet_pointer orelse return;
if (!pipeline.accepting_packets.load(.acquire)) return;
const parsed = rtp.parse(packet[0..length]) catch return;
- if (parsed.header.payload_type != payload_type or parsed.payload.len == 0) return;
+ if (parsed.header.payload_type != pipeline.payload_type.load(.acquire) or
+ parsed.payload.len == 0) return;
_ = pipeline.rtp_packets.fetchAdd(1, .monotonic);
if (parsed.payload.len > packet_capacity) {
_ = pipeline.dropped_packets.fetchAdd(1, .monotonic);
diff --git a/src/media/clip/decoder.zig b/src/media/clip/decoder.zig
new file mode 100644
index 0000000..46b0513
--- /dev/null
+++ b/src/media/clip/decoder.zig
@@ -0,0 +1,196 @@
+const std = @import("std");
+pub const c = @cImport({
+ @cInclude("libavcodec/avcodec.h");
+ @cInclude("libavformat/avformat.h");
+ @cInclude("libavutil/error.h");
+ @cInclude("libswresample/swresample.h");
+});
+
+const sample_rate = 48000;
+
+const Input = struct {
+ bytes: []const u8,
+ position: usize = 0,
+
+ fn read(pointer: ?*anyopaque, output: [*c]u8, capacity: c_int) callconv(.c) c_int {
+ const self: *Input = @ptrCast(@alignCast(pointer.?));
+ if (capacity <= 0) return c.AVERROR(c.EINVAL);
+ if (self.position == self.bytes.len) return c.AVERROR_EOF;
+ const count = @min(@as(usize, @intCast(capacity)), self.bytes.len - self.position);
+ @memcpy(output[0..count], self.bytes[self.position..][0..count]);
+ self.position += count;
+ return @intCast(count);
+ }
+
+ fn seek(pointer: ?*anyopaque, offset: i64, origin: c_int) callconv(.c) i64 {
+ const self: *Input = @ptrCast(@alignCast(pointer.?));
+ if (origin == c.AVSEEK_SIZE) return @intCast(self.bytes.len);
+ const base: i64 = switch (origin & ~@as(c_int, c.AVSEEK_FORCE)) {
+ c.SEEK_SET => 0,
+ c.SEEK_CUR => @intCast(self.position),
+ c.SEEK_END => @intCast(self.bytes.len),
+ else => return -1,
+ };
+ const next = std.math.add(i64, base, offset) catch return -1;
+ if (next < 0 or next > self.bytes.len) return -1;
+ self.position = @intCast(next);
+ return next;
+ }
+};
+
+pub const Frame = struct {
+ video: bool,
+ timestamp_ms: i64,
+ frame: *c.AVFrame,
+};
+
+pub const Decoder = struct {
+ input: Input,
+ io: [*c]c.AVIOContext = null,
+ format: [*c]c.AVFormatContext = null,
+ contexts: [2][*c]c.AVCodecContext = .{ null, null },
+ streams: [2]c_int = .{ -1, -1 },
+ packet: [*c]c.AVPacket = null,
+ frame: [*c]c.AVFrame = null,
+ receiving: ?usize = null,
+ input_ended: bool = false,
+ flush_index: usize = 0,
+ resampler: ?*c.SwrContext = null,
+ start_ms: i64 = 0,
+
+ pub fn open(bytes: []const u8) !*Decoder {
+ if (bytes.len == 0 or bytes.len > 96 * 1024 * 1024) return error.InvalidMediaSize;
+ const self = try std.heap.c_allocator.create(Decoder);
+ self.* = .{ .input = .{ .bytes = bytes } };
+ errdefer self.destroy();
+ const buffer: [*c]u8 = @ptrCast(c.av_malloc(32768));
+ if (buffer == null) return error.OutOfMemory;
+ self.io = c.avio_alloc_context(buffer, 32768, 0, &self.input, Input.read, null, Input.seek);
+ if (self.io == null) {
+ c.av_free(buffer);
+ return error.OutOfMemory;
+ }
+ self.format = c.avformat_alloc_context();
+ if (self.format == null) return error.OutOfMemory;
+ self.format.*.pb = self.io;
+ self.format.*.flags |= c.AVFMT_FLAG_CUSTOM_IO;
+ self.format.*.probesize = 1024 * 1024;
+ self.format.*.max_analyze_duration = 3 * c.AV_TIME_BASE;
+ const input_format = c.av_find_input_format("mov");
+ if (input_format == null) return error.Mp4Unavailable;
+ if (c.avformat_open_input(&self.format, null, input_format, null) < 0 or
+ c.avformat_find_stream_info(self.format, null) < 0) return error.InvalidMp4;
+ if (self.format.*.duration > 120 * c.AV_TIME_BASE) return error.AdMediaTooLong;
+ if (self.format.*.start_time != c.AV_NOPTS_VALUE)
+ self.start_ms = @divTrunc(self.format.*.start_time, 1000);
+ if (self.format.*.nb_streams > 8) return error.TooManyStreams;
+ for ([_]c.AVMediaType{ c.AVMEDIA_TYPE_VIDEO, c.AVMEDIA_TYPE_AUDIO }, 0..) |kind, index| {
+ const stream_index = c.av_find_best_stream(self.format, kind, -1, -1, null, 0);
+ if (stream_index < 0) {
+ if (index == 0) return error.MissingVideo;
+ continue;
+ }
+ const parameters = self.format.*.streams[@intCast(stream_index)].*.codecpar;
+ if (parameters.*.codec_id != (if (index == 0) c.AV_CODEC_ID_H264 else c.AV_CODEC_ID_AAC))
+ return error.UnsupportedCodec;
+ if (index == 0 and (parameters.*.width <= 0 or parameters.*.height <= 0 or
+ parameters.*.width > 1280 or parameters.*.height > 768)) return error.UnsupportedDimensions;
+ if (index == 1 and (parameters.*.sample_rate <= 0 or parameters.*.sample_rate > 96000 or
+ parameters.*.ch_layout.nb_channels <= 0 or parameters.*.ch_layout.nb_channels > 8))
+ return error.UnsupportedAudio;
+ const codec = c.avcodec_find_decoder(parameters.*.codec_id);
+ if (codec == null) return error.CodecUnavailable;
+ const context = c.avcodec_alloc_context3(codec);
+ if (context == null) return error.OutOfMemory;
+ self.contexts[index] = context;
+ self.streams[index] = stream_index;
+ context.*.thread_count = 3;
+ context.*.max_pixels = 1280 * 768;
+ if (c.avcodec_parameters_to_context(context, parameters) < 0 or
+ c.avcodec_open2(context, codec, null) < 0) return error.DecoderUnavailable;
+ }
+ self.packet = c.av_packet_alloc();
+ self.frame = c.av_frame_alloc();
+ if (self.packet == null or self.frame == null) return error.OutOfMemory;
+ return self;
+ }
+
+ pub fn next(self: *Decoder) !?Frame {
+ for (0..4096) |_| {
+ if (self.receiving) |index| {
+ c.av_frame_unref(self.frame);
+ const result = c.avcodec_receive_frame(self.contexts[index], self.frame);
+ if (result == 0) {
+ const time_base = self.format.*.streams[@intCast(self.streams[index])].*.time_base;
+ const pts = self.frame.*.best_effort_timestamp;
+ if (pts == c.AV_NOPTS_VALUE) return error.MissingTimestamp;
+ const timestamp_ms = std.math.sub(i64, c.av_rescale_q(pts, time_base, .{ .num = 1, .den = 1000 }), self.start_ms) catch return error.InvalidMediaTimestamp;
+ if (timestamp_ms < -1000 or timestamp_ms > 120000) return error.InvalidMediaTimestamp;
+ return .{ .video = index == 0, .frame = self.frame, .timestamp_ms = timestamp_ms };
+ }
+ if (result != c.AVERROR(c.EAGAIN) and result != c.AVERROR_EOF) return error.DecodeFailed;
+ self.receiving = null;
+ }
+ if (self.input_ended) {
+ if (self.flush_index == 2) return null;
+ const index = self.flush_index;
+ self.flush_index += 1;
+ if (self.contexts[index] == null) continue;
+ if (c.avcodec_send_packet(self.contexts[index], null) < 0) return error.DecodeFailed;
+ self.receiving = index;
+ continue;
+ }
+ const result = c.av_read_frame(self.format, self.packet);
+ if (result == c.AVERROR_EOF) {
+ self.input_ended = true;
+ continue;
+ }
+ if (result < 0) return error.DemuxFailed;
+ defer c.av_packet_unref(self.packet);
+ for (self.streams, 0..) |stream, index| {
+ if (self.packet.*.stream_index != stream) continue;
+ if (c.avcodec_send_packet(self.contexts[index], self.packet) < 0) return error.DecodeFailed;
+ self.receiving = index;
+ break;
+ }
+ }
+ return error.NonProgressingMedia;
+ }
+
+ pub fn audio(self: *Decoder, frame: *const c.AVFrame, output: []i16) ![]i16 {
+ if (frame.sample_rate <= 0 or frame.sample_rate > 96000 or frame.ch_layout.nb_channels <= 0 or
+ frame.ch_layout.nb_channels > 8 or frame.nb_samples < 0) return error.UnsupportedAudio;
+ if (self.resampler == null) {
+ var stereo: c.AVChannelLayout = undefined;
+ c.av_channel_layout_default(&stereo, 2);
+ defer c.av_channel_layout_uninit(&stereo);
+ if (c.swr_alloc_set_opts2(&self.resampler, &stereo, c.AV_SAMPLE_FMT_S16, sample_rate, &frame.ch_layout, @intCast(frame.format), frame.sample_rate, 0, null) < 0 or
+ c.swr_init(self.resampler) < 0) return error.ResamplerUnavailable;
+ }
+ const required = c.swr_get_out_samples(self.resampler, frame.nb_samples);
+ if (required < 0 or required > output.len / 2) return error.AudioLimitExceeded;
+ var destination = [_][*c]u8{@ptrCast(output.ptr)};
+ const count = c.swr_convert(self.resampler, &destination, @intCast(output.len / 2), @ptrCast(frame.extended_data), frame.nb_samples);
+ if (count < 0) return error.ResampleFailed;
+ return output[0 .. @as(usize, @intCast(count)) * 2];
+ }
+
+ pub fn finishAudio(self: *Decoder, output: []i16) ![]i16 {
+ if (self.resampler == null) return output[0..0];
+ var destination = [_][*c]u8{@ptrCast(output.ptr)};
+ const count = c.swr_convert(self.resampler, &destination, @intCast(output.len / 2), null, 0);
+ if (count < 0) return error.ResampleFailed;
+ return output[0 .. @as(usize, @intCast(count)) * 2];
+ }
+
+ pub fn destroy(self: *Decoder) void {
+ c.swr_free(&self.resampler);
+ c.av_frame_free(&self.frame);
+ c.av_packet_free(&self.packet);
+ for (&self.contexts) |*context| c.avcodec_free_context(context);
+ c.avformat_close_input(&self.format);
+ if (self.io != null) c.av_freep(@ptrCast(&self.io.*.buffer));
+ c.avio_context_free(&self.io);
+ std.heap.c_allocator.destroy(self);
+ }
+};
diff --git a/src/media/clip/player.zig b/src/media/clip/player.zig
new file mode 100644
index 0000000..b32d5dc
--- /dev/null
+++ b/src/media/clip/player.zig
@@ -0,0 +1,277 @@
+const std = @import("std");
+const media = @import("decoder.zig");
+const c = @cImport({
+ @cInclude("SDL2/SDL.h");
+});
+
+const BufferedFrame = struct { frame: *media.c.AVFrame, pts: i64, duration: i64 };
+const queue_capacity = 16;
+const bytes_per_second = 48000 * 2 * @sizeOf(i16);
+
+pub const Progress = struct {
+ played_ms: u64,
+ rendered_frames: u64,
+ dropped_frames: u64,
+ finished: bool,
+};
+
+pub const Player = struct {
+ bytes: []const u8,
+ device: c.SDL_AudioDeviceID,
+ renderer: *c.SDL_Renderer,
+ texture: ?*c.SDL_Texture = null,
+ texture_width: c_int = 0,
+ texture_height: c_int = 0,
+ thread: ?std.Thread = null,
+ stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ total_samples: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
+ has_audio: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ ended: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ mutex: std.Thread.Mutex = .{},
+ condition: std.Thread.Condition = .{},
+ queue: [queue_capacity]BufferedFrame = undefined,
+ head: usize = 0,
+ count: usize = 0,
+ failure: ?anyerror = null,
+ started_at: ?u64 = null,
+ visible_end_ms: u64 = 0,
+ rendered_frames: u64 = 0,
+ dropped_frames: u64 = 0,
+ paused_at: ?u64 = null,
+ paused_ms: u64 = 0,
+ previous_yuv_mode: c.SDL_YUV_CONVERSION_MODE = c.SDL_YUV_CONVERSION_AUTOMATIC,
+ previous_audio_status: ?c.SDL_AudioStatus = null,
+ last_audio_position: u64 = 0,
+ last_audio_progress_at: u64 = 0,
+ last_position: u64 = 0,
+
+ pub fn start(self: *Player) !void {
+ if (self.thread != null) return error.AlreadyPlaying;
+ self.previous_yuv_mode = c.SDL_GetYUVConversionMode();
+ self.previous_audio_status = c.SDL_GetAudioDeviceStatus(self.device);
+ errdefer self.restoreDevice();
+ c.SDL_PauseAudioDevice(self.device, 1);
+ c.SDL_ClearQueuedAudio(self.device);
+ self.thread = try std.Thread.spawn(.{}, worker, .{self});
+ }
+
+ fn worker(self: *Player) void {
+ self.decode() catch |err| {
+ self.mutex.lock();
+ self.failure = err;
+ self.mutex.unlock();
+ };
+ self.ended.store(true, .release);
+ }
+
+ fn decode(self: *Player) !void {
+ const decoder = try media.Decoder.open(self.bytes);
+ defer decoder.destroy();
+ self.has_audio.store(decoder.contexts[1] != null, .release);
+ var pcm: [65536]i16 = undefined;
+ while (!self.stop.load(.acquire)) {
+ const event = try decoder.next() orelse {
+ while (true) {
+ const samples = try decoder.finishAudio(&pcm);
+ if (samples.len == 0) return;
+ try self.queueAudio(samples);
+ }
+ };
+ if (event.video) {
+ const cloned = media.c.av_frame_clone(event.frame);
+ if (cloned == null) return error.OutOfMemory;
+ self.mutex.lock();
+ while (self.count == queue_capacity and !self.stop.load(.acquire))
+ self.condition.wait(&self.mutex);
+ if (self.stop.load(.acquire)) {
+ self.mutex.unlock();
+ var frame = cloned;
+ media.c.av_frame_free(&frame);
+ return;
+ }
+ const duration = media.c.av_rescale_q(event.frame.duration, decoder.format.*.streams[@intCast(decoder.streams[0])].*.time_base, .{ .num = 1, .den = 1000 });
+ const rate = decoder.format.*.streams[@intCast(decoder.streams[0])].*.avg_frame_rate;
+ const frame_duration = if (duration > 0) duration else if (rate.num > 0 and rate.den > 0)
+ media.c.av_rescale(1000, rate.den, rate.num)
+ else
+ 0;
+ if (frame_duration <= 0 or frame_duration > 1000) {
+ self.mutex.unlock();
+ var frame = cloned;
+ media.c.av_frame_free(&frame);
+ return error.MissingFrameDuration;
+ }
+ self.queue[(self.head + self.count) % queue_capacity] = .{
+ .frame = cloned,
+ .pts = @max(0, event.timestamp_ms),
+ .duration = frame_duration,
+ };
+ self.count += 1;
+ self.mutex.unlock();
+ } else {
+ const samples = try decoder.audio(event.frame, &pcm);
+ try self.queueAudio(samples);
+ }
+ }
+ }
+
+ fn queueAudio(self: *Player, samples: []const i16) !void {
+ while (c.SDL_GetQueuedAudioSize(self.device) >= bytes_per_second / 2) {
+ if (self.stop.load(.acquire)) return error.Cancelled;
+ std.Thread.sleep(2 * std.time.ns_per_ms);
+ }
+ if (self.stop.load(.acquire)) return error.Cancelled;
+ if (c.SDL_QueueAudio(self.device, samples.ptr, @intCast(samples.len * @sizeOf(i16))) != 0)
+ return error.AudioQueueFailed;
+ _ = self.total_samples.fetchAdd(samples.len / 2, .release);
+ }
+
+ pub fn pause(self: *Player, paused: bool) void {
+ if (self.started_at == null) return;
+ const now = c.SDL_GetTicks64();
+ if (paused and self.paused_at == null) {
+ self.paused_at = now;
+ c.SDL_PauseAudioDevice(self.device, 1);
+ } else if (!paused and self.paused_at != null) {
+ self.paused_ms += now - self.paused_at.?;
+ self.paused_at = null;
+ if (self.started_at != null) c.SDL_PauseAudioDevice(self.device, 0);
+ }
+ }
+
+ fn playhead(self: *Player, now: u64) u64 {
+ const start_time = self.started_at orelse return 0;
+ if (!self.has_audio.load(.acquire)) return (self.paused_at orelse now) - start_time - self.paused_ms;
+ const queued_samples = c.SDL_GetQueuedAudioSize(self.device) / (2 * @sizeOf(i16));
+ const samples = self.total_samples.load(.acquire);
+ const audio_position = (samples -| queued_samples) * 1000 / 48000;
+ const effective_now = (self.paused_at orelse now) - self.paused_ms;
+ if (audio_position > self.last_audio_position or self.last_audio_progress_at == 0) {
+ self.last_audio_position = audio_position;
+ self.last_audio_progress_at = effective_now;
+ }
+ const position = audio_position + if (queued_samples == 0) effective_now - self.last_audio_progress_at else 0;
+ self.last_position = @max(self.last_position, position);
+ return self.last_position;
+ }
+
+ fn render(self: *Player, frame: *const media.c.AVFrame) !void {
+ if (frame.width <= 0 or frame.height <= 0 or frame.width > 1280 or frame.height > 768)
+ return error.InvalidFrame;
+ if (frame.format != media.c.AV_PIX_FMT_YUV420P and frame.format != media.c.AV_PIX_FMT_YUVJ420P)
+ return error.UnsupportedPixelFormat;
+ if (self.texture == null or self.texture_width != frame.width or self.texture_height != frame.height) {
+ if (self.texture) |texture| c.SDL_DestroyTexture(texture);
+ self.texture = c.SDL_CreateTexture(self.renderer, c.SDL_PIXELFORMAT_IYUV, c.SDL_TEXTUREACCESS_STREAMING, frame.width, frame.height);
+ if (self.texture == null) return error.TextureUnavailable;
+ self.texture_width = frame.width;
+ self.texture_height = frame.height;
+ }
+ c.SDL_SetYUVConversionMode(if (frame.color_range == media.c.AVCOL_RANGE_JPEG)
+ c.SDL_YUV_CONVERSION_JPEG
+ else if (frame.colorspace == media.c.AVCOL_SPC_BT709)
+ c.SDL_YUV_CONVERSION_BT709
+ else
+ c.SDL_YUV_CONVERSION_BT601);
+ if (c.SDL_UpdateYUVTexture(self.texture, null, frame.data[0], frame.linesize[0], frame.data[1], frame.linesize[1], frame.data[2], frame.linesize[2]) != 0)
+ return error.TextureUpdateFailed;
+ }
+
+ pub fn draw(self: *Player) !void {
+ if (self.texture == null) return;
+ var width: c_int = undefined;
+ var height: c_int = undefined;
+ const size = try canvasSize(self.renderer);
+ width = size.width;
+ height = size.height;
+ const scale = @min(@as(f64, @floatFromInt(width)) / @as(f64, @floatFromInt(self.texture_width)), @as(f64, @floatFromInt(height)) / @as(f64, @floatFromInt(self.texture_height)));
+ const scaled_width: c_int = @intFromFloat(@as(f64, @floatFromInt(self.texture_width)) * scale);
+ const scaled_height: c_int = @intFromFloat(@as(f64, @floatFromInt(self.texture_height)) * scale);
+ const destination = c.SDL_Rect{ .x = @divTrunc(width - scaled_width, 2), .y = @divTrunc(height - scaled_height, 2), .w = scaled_width, .h = scaled_height };
+ _ = c.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 255);
+ _ = c.SDL_RenderClear(self.renderer);
+ if (c.SDL_RenderCopy(self.renderer, self.texture, null, &destination) != 0) return error.RenderFailed;
+ }
+
+ pub fn tick(self: *Player) !Progress {
+ const now = c.SDL_GetTicks64();
+ self.mutex.lock();
+ const failure = self.failure;
+ const available = self.count;
+ self.mutex.unlock();
+ if (failure) |err| return err;
+ if (self.paused_at == null and self.started_at == null and available > 0 and
+ (!self.has_audio.load(.acquire) or c.SDL_GetQueuedAudioSize(self.device) >= bytes_per_second / 5 or self.ended.load(.acquire)))
+ {
+ self.started_at = now;
+ c.SDL_PauseAudioDevice(self.device, 0);
+ }
+ if (self.started_at == null) return .{ .played_ms = 0, .rendered_frames = 0, .dropped_frames = 0, .finished = false };
+ const position = self.playhead(now);
+ while (self.paused_at == null) {
+ self.mutex.lock();
+ if (self.count == 0 or self.queue[self.head].pts > position) {
+ self.mutex.unlock();
+ break;
+ }
+ const next = self.queue[self.head];
+ self.head = (self.head + 1) % queue_capacity;
+ self.count -= 1;
+ const empty = self.count == 0;
+ self.condition.signal();
+ self.mutex.unlock();
+ var owned: [*c]media.c.AVFrame = next.frame;
+ defer media.c.av_frame_free(&owned);
+ if (position > @as(u64, @intCast(next.pts)) + 80 and !empty) {
+ self.dropped_frames += 1;
+ continue;
+ }
+ try self.render(next.frame);
+ self.rendered_frames += 1;
+ self.visible_end_ms = @intCast(next.pts + next.duration);
+ }
+ self.mutex.lock();
+ const drained = self.count == 0;
+ self.mutex.unlock();
+ const complete = self.ended.load(.acquire) and drained and
+ c.SDL_GetQueuedAudioSize(self.device) == 0 and position + 2 >= self.visible_end_ms and self.rendered_frames > 0;
+ return .{ .played_ms = @min(position, self.visible_end_ms), .rendered_frames = self.rendered_frames, .dropped_frames = self.dropped_frames, .finished = complete };
+ }
+
+ pub fn deinit(self: *Player) void {
+ self.stop.store(true, .release);
+ self.mutex.lock();
+ self.condition.broadcast();
+ self.mutex.unlock();
+ if (self.thread) |thread| thread.join();
+ self.thread = null;
+ while (self.count > 0) {
+ var frame: [*c]media.c.AVFrame = self.queue[self.head].frame;
+ media.c.av_frame_free(&frame);
+ self.head = (self.head + 1) % queue_capacity;
+ self.count -= 1;
+ }
+ self.restoreDevice();
+ if (self.texture) |texture| c.SDL_DestroyTexture(texture);
+ self.texture = null;
+ }
+
+ fn restoreDevice(self: *Player) void {
+ if (self.previous_audio_status) |status| {
+ c.SDL_ClearQueuedAudio(self.device);
+ c.SDL_SetYUVConversionMode(self.previous_yuv_mode);
+ c.SDL_PauseAudioDevice(self.device, @intFromBool(status != c.SDL_AUDIO_PLAYING));
+ self.previous_audio_status = null;
+ }
+ }
+};
+
+pub fn canvasSize(renderer: *c.SDL_Renderer) !struct { width: c_int, height: c_int } {
+ var width: c_int = 0;
+ var height: c_int = 0;
+ c.SDL_RenderGetLogicalSize(renderer, &width, &height);
+ if ((width == 0 or height == 0) and c.SDL_GetRendererOutputSize(renderer, &width, &height) != 0)
+ return error.RendererUnavailable;
+ if (width <= 0 or height <= 0) return error.RendererUnavailable;
+ return .{ .width = width, .height = height };
+}
diff --git a/src/media/video/cedar_bridge.c b/src/media/video/cedar_bridge.c
index d64311f..ffc88b4 100644
--- a/src/media/video/cedar_bridge.c
+++ b/src/media/video/cedar_bridge.c
@@ -32,7 +32,7 @@ static size_t find_start_code(const uint8_t* data, size_t length, size_t offset)
return length;
}
-static int submit_nal(GoCedarDecoder* decoder, const uint8_t* data, size_t length) {
+static int submit_access_unit(GoCedarDecoder* decoder, const uint8_t* data, size_t length) {
if (length == 0 || length > INT32_MAX)
return -1;
char* first = NULL;
@@ -172,14 +172,9 @@ int go_cedar_v1_feed(GoCedarDecoder* decoder, const uint8_t* annex_b, size_t len
set_error(decoder, "H.264 access unit is not Annex B");
return -1;
}
- while (start < length) {
- size_t prefix = annex_b[start + 2] == 1 ? 3u : 4u;
- size_t end = find_start_code(annex_b, length, start + prefix);
- if (submit_nal(decoder, annex_b + start, end - start) != 0) {
- set_error(decoder, "Cedar stream buffer rejected a NAL unit");
- return -1;
- }
- start = end;
+ if (submit_access_unit(decoder, annex_b + start, length - start) != 0) {
+ set_error(decoder, "Cedar stream buffer rejected an access unit");
+ return -1;
}
for (int attempt = 0; attempt < 64; ++attempt) {
@@ -187,6 +182,15 @@ int go_cedar_v1_feed(GoCedarDecoder* decoder, const uint8_t* annex_b, size_t len
int copied = copy_newest_picture(decoder, frame);
if (copied < 0)
return -1;
+ if (result == VDECODE_RESULT_RESOLUTION_CHANGE) {
+ if (ReopenVideoEngine(decoder->decoder) != 0) {
+ set_error(decoder, "Cedar resolution change failed");
+ return -1;
+ }
+ if (copied > 0)
+ return 1;
+ continue;
+ }
if (copied > 0)
return 1;
if (result == VDECODE_RESULT_NO_BITSTREAM || result == VDECODE_RESULT_CONTINUE ||
diff --git a/src/media/video/video_pipeline.h b/src/media/video/video_pipeline.h
index 9acf685..1690f14 100644
--- a/src/media/video/video_pipeline.h
+++ b/src/media/video/video_pipeline.h
@@ -59,8 +59,9 @@ typedef struct {
GoVideoPipeline* go_video_pipeline_create(const GoVideoPipelineConfig* config);
int go_video_pipeline_start(GoVideoPipeline* pipeline);
void go_video_pipeline_stop(GoVideoPipeline* pipeline);
+int go_video_pipeline_set_payload_type(GoVideoPipeline* pipeline, int payload_type);
void go_video_pipeline_push_rtp(GoVideoPipeline* pipeline, const uint8_t* packet, size_t length);
-void go_video_pipeline_render(GoVideoPipeline* pipeline);
+int go_video_pipeline_render(GoVideoPipeline* pipeline);
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..d7c31d3 100644
--- a/src/media/video/video_pipeline.zig
+++ b/src/media/video/video_pipeline.zig
@@ -354,7 +354,6 @@ fn decodeAccessUnit(
fn processPacket(pipeline: *Pipeline, packet: []const u8) void {
if (packet.len < 12 or pipeline.depacketizer == null) return;
- _ = pipeline.rtp_packets.fetchAdd(1, .monotonic);
const result = c.go_h264_depacketizer_feed(
pipeline.depacketizer,
packet.ptr,
@@ -406,6 +405,7 @@ fn worker(pipeline: *Pipeline) void {
const packet = pipeline.packet_queue[pipeline.packet_queue_head];
pipeline.packet_queue_head = (pipeline.packet_queue_head + 1) % queue_capacity;
pipeline.packet_queue_count -= 1;
+ _ = pipeline.rtp_packets.fetchAdd(1, .monotonic);
pipeline.packet_mutex.unlock();
if (pipeline.restart_epoch_pending.swap(false, .acq_rel))
c.go_h264_depacketizer_restart_decode_epoch(pipeline.depacketizer);
@@ -552,7 +552,10 @@ pub export fn go_video_pipeline_create(config_pointer: ?*const c.GoVideoPipeline
.max_width = config.max_width,
.max_height = config.max_height,
};
- errdefer _ = go_video_pipeline_destroy(pipeline);
+ var initialized = false;
+ defer if (!initialized) {
+ _ = go_video_pipeline_destroy(pipeline);
+ };
if (config.bootstrap_path != null) {
const path = std.mem.span(config.bootstrap_path);
if (path.len >= pipeline.bootstrap_path.len) return null;
@@ -566,6 +569,7 @@ pub export fn go_video_pipeline_create(config_pointer: ?*const c.GoVideoPipeline
if (pipeline.decoded_frame == null or pipeline.display_frame == null or pipeline.render_frame == null)
return null;
if (selectDecoder(pipeline, config.decoder_preference) != 0) return null;
+ initialized = true;
return pipeline;
}
@@ -581,6 +585,21 @@ pub export fn go_video_pipeline_start(pipeline_pointer: ?*Pipeline) c_int {
return 0;
}
+pub export fn go_video_pipeline_set_payload_type(
+ pipeline_pointer: ?*Pipeline,
+ payload_type: c_int,
+) c_int {
+ const pipeline = pipeline_pointer orelse return -1;
+ if (payload_type < 0 or payload_type > 127) return -1;
+ pipeline.packet_mutex.lock();
+ defer pipeline.packet_mutex.unlock();
+ if (pipeline.packet_queue_count != 0 or pipeline.rtp_packets.load(.monotonic) != 0) return -1;
+ const replacement = c.go_h264_depacketizer_create(@intCast(payload_type)) orelse return -1;
+ c.go_h264_depacketizer_destroy(pipeline.depacketizer);
+ pipeline.depacketizer = replacement;
+ return loadBootstrap(pipeline);
+}
+
pub export fn go_video_pipeline_stop(pipeline_pointer: ?*Pipeline) void {
const pipeline = pipeline_pointer orelse return;
pipeline.accepting_packets.store(false, .release);
@@ -629,9 +648,9 @@ pub export fn go_video_pipeline_push_rtp(
pipeline.packet_condition.signal();
}
-pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void {
- const pipeline = pipeline_pointer orelse return;
- if (!pipeline.frame_ready.load(.acquire)) return;
+pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) c_int {
+ const pipeline = pipeline_pointer orelse return 0;
+ if (!pipeline.frame_ready.load(.acquire)) return 0;
pipeline.frame_mutex.lock();
if (pipeline.frame_ready.load(.acquire)) {
c.av_frame_unref(pipeline.render_frame);
@@ -639,14 +658,14 @@ pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void {
pipeline.frame_ready.store(false, .release);
}
pipeline.frame_mutex.unlock();
- const frame = pipeline.render_frame orelse return;
+ const frame = pipeline.render_frame orelse return 0;
if (frame.data[0] == null or uploadFrame(pipeline, frame) < 0) {
if (!pipeline.upload_error_reported) {
std.debug.print("Frame upload failed: {s}\n", .{std.mem.span(c.SDL_GetError())});
pipeline.upload_error_reported = true;
}
c.av_frame_unref(frame);
- return;
+ return 0;
}
var output_width: c_int = 0;
var output_height: c_int = 0;
@@ -663,9 +682,9 @@ pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void {
_ = c.SDL_SetRenderDrawColor(pipeline.renderer, 0, 0, 0, 255);
_ = c.SDL_RenderClear(pipeline.renderer);
_ = c.SDL_RenderCopy(pipeline.renderer, pipeline.texture, &source, &destination);
- c.SDL_RenderPresent(pipeline.renderer);
_ = pipeline.rendered_frames.fetchAdd(1, .monotonic);
c.av_frame_unref(frame);
+ return 1;
}
pub export fn go_video_pipeline_needs_keyframe(pipeline: ?*const Pipeline) c_int {
diff --git a/src/net/form_writer.zig b/src/net/form_writer.zig
index 7f73d4a..30e0abf 100644
--- a/src/net/form_writer.zig
+++ b/src/net/form_writer.zig
@@ -1,5 +1,10 @@
const std = @import("std");
+pub const Field = struct {
+ name: []const u8,
+ value: []const u8,
+};
+
fn isUnreserved(byte: u8) bool {
return std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '.' or byte == '_' or
byte == '~';
@@ -38,6 +43,28 @@ pub fn encode(input: []const u8, output: []u8) !usize {
return cursor;
}
+pub fn build(fields: []const Field, output: []u8) ![:0]u8 {
+ if (output.len == 0) return error.NoSpace;
+
+ var cursor: usize = 0;
+ for (fields, 0..) |field, index| {
+ if (index != 0) {
+ if (cursor + 1 >= output.len) return error.NoSpace;
+ output[cursor] = '&';
+ cursor += 1;
+ }
+
+ cursor += try encode(field.name, output[cursor..]);
+ if (cursor + 1 >= output.len) return error.NoSpace;
+ output[cursor] = '=';
+ cursor += 1;
+ cursor += try encode(field.value, output[cursor..]);
+ }
+
+ output[cursor] = 0;
+ return output[0..cursor :0];
+}
+
test "encodes application form values and terminates them" {
var output: [256]u8 = undefined;
const input = "xboxlive.signin openid service::http://Passport.NET/?a=b&c=d";
@@ -55,3 +82,27 @@ test "preserves unreserved bytes and rejects undersized output" {
try std.testing.expectEqualStrings("a-._~Z", output[0..length]);
try std.testing.expectError(error.NoSpace, encode("a/b", output[0..5]));
}
+
+test "builds an encoded form body" {
+ var output: [128]u8 = undefined;
+ const body = try build(&.{
+ .{ .name = "grant_type", .value = "refresh_token" },
+ .{ .name = "scope", .value = "openid email" },
+ .{ .name = "device/id", .value = "a&b" },
+ }, &output);
+ try std.testing.expectEqualStrings(
+ "grant_type=refresh_token&scope=openid+email&device%2Fid=a%26b",
+ body,
+ );
+}
+
+test "build handles an empty form and rejects short destinations" {
+ var empty: [1]u8 = undefined;
+ try std.testing.expectEqualStrings("", try build(&.{}, &empty));
+
+ var short: [3]u8 = undefined;
+ try std.testing.expectError(
+ error.NoSpace,
+ build(&.{.{ .name = "a", .value = "b" }}, &short),
+ );
+}
diff --git a/src/net/http_client.c b/src/net/http_client.c
index f0b51f0..c11c338 100644
--- a/src/net/http_client.c
+++ b/src/net/http_client.c
@@ -1,6 +1,7 @@
#include "http_client.h"
#include
+#include
#include
#include
#include
@@ -21,7 +22,15 @@ typedef struct {
void* context;
} TransferCancel;
-static const char* find_ca_bundle(void) {
+static void print_http_result(FILE* stream, const char* prefix, const char* url,
+ const char* suffix) {
+ const char* query = strchr(url, '?');
+ size_t visible_length = query ? (size_t)(query - url) : strlen(url);
+ int precision = visible_length > INT_MAX ? INT_MAX : (int)visible_length;
+ fprintf(stream, "%s%.*s%s%s", prefix, precision, url, query ? "?[redacted]" : "", suffix);
+}
+
+const char* go_http_ca_bundle(void) {
const char* environment_paths[] = {
getenv("CURL_CA_BUNDLE"),
getenv("SSL_CERT_FILE"),
@@ -68,9 +77,8 @@ static size_t append_response(char* data, size_t size, size_t count, void* conte
return length;
}
-static int transfer_cancelled(void* context, curl_off_t download_total,
- curl_off_t download_current, curl_off_t upload_total,
- curl_off_t upload_current) {
+static int transfer_cancelled(void* context, curl_off_t download_total, curl_off_t download_current,
+ curl_off_t upload_total, curl_off_t upload_current) {
(void)download_total;
(void)download_current;
(void)upload_total;
@@ -90,10 +98,11 @@ int go_http_response_succeeded(const GoHttpResponse* response) {
return response && response->status >= 200 && response->status < 300;
}
-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) {
+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) {
if (!method || !url || response_limit == 0 || header_count < 0 ||
(header_count > 0 && !headers))
return NULL;
@@ -120,14 +129,9 @@ GoHttpResponse* go_http_request_bounded_cancelable(
}
curl_easy_setopt(request, CURLOPT_URL, url);
- if (strcmp(method, "POST") == 0) {
- curl_easy_setopt(request, CURLOPT_POST, 1L);
+ curl_easy_setopt(request, CURLOPT_CUSTOMREQUEST, method);
+ if (body || strcmp(method, "POST") == 0)
curl_easy_setopt(request, CURLOPT_POSTFIELDS, body ? body : "");
- } else if (strcmp(method, "DELETE") == 0) {
- curl_easy_setopt(request, CURLOPT_CUSTOMREQUEST, "DELETE");
- } else if (strcmp(method, "GET") != 0) {
- curl_easy_setopt(request, CURLOPT_CUSTOMREQUEST, method);
- }
if (request_headers)
curl_easy_setopt(request, CURLOPT_HTTPHEADER, request_headers);
ResponseWriter writer = {.response = response, .limit = response_limit};
@@ -135,7 +139,7 @@ GoHttpResponse* go_http_request_bounded_cancelable(
curl_easy_setopt(request, CURLOPT_WRITEDATA, &writer);
curl_easy_setopt(request, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(request, CURLOPT_NOSIGNAL, 1L);
- const char* ca_bundle = find_ca_bundle();
+ const char* ca_bundle = go_http_ca_bundle();
if (ca_bundle)
curl_easy_setopt(request, CURLOPT_CAINFO, ca_bundle);
TransferCancel cancel = {.requested = cancel_requested, .context = cancel_context};
@@ -148,12 +152,23 @@ GoHttpResponse* go_http_request_bounded_cancelable(
CURLcode result = curl_easy_perform(request);
curl_easy_getinfo(request, CURLINFO_RESPONSE_CODE, &response->status);
if (result != CURLE_OK) {
- if (result != CURLE_ABORTED_BY_CALLBACK)
- fprintf(stderr, "HTTP %s %s: %s\n", method, url, curl_easy_strerror(result));
+ if (result != CURLE_ABORTED_BY_CALLBACK) {
+ char prefix[64];
+ char suffix[160];
+ snprintf(prefix, sizeof(prefix), "HTTP %s ", method);
+ snprintf(suffix, sizeof(suffix), ": %s\n", curl_easy_strerror(result));
+ print_http_result(stderr, prefix, url, suffix);
+ }
go_http_response_destroy(response);
response = NULL;
} else {
- go_dbg("HTTP %ld %s (%zu bytes)\n", response->status, url, response->len);
+ if (go_debug_enabled()) {
+ char prefix[64];
+ char suffix[64];
+ snprintf(prefix, sizeof(prefix), "HTTP %ld ", response->status);
+ snprintf(suffix, sizeof(suffix), " (%zu bytes)\n", response->len);
+ print_http_result(stderr, prefix, url, suffix);
+ }
}
curl_slist_free_all(request_headers);
diff --git a/src/net/http_client.h b/src/net/http_client.h
index 1d23585..ed4a416 100644
--- a/src/net/http_client.h
+++ b/src/net/http_client.h
@@ -15,15 +15,18 @@ typedef struct {
typedef int (*GoHttpCancelRequested)(void* context);
+const char* go_http_ca_bundle(void);
+
GoHttpResponse* go_http_request(const char* method, const char* url, const char* body,
const char** headers, int header_count);
GoHttpResponse* go_http_request_bounded(const char* method, const char* url, const char* body,
const char** headers, int header_count,
size_t response_limit);
-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);
+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);
int go_http_response_succeeded(const GoHttpResponse* response);
void go_http_response_destroy(GoHttpResponse* response);
diff --git a/src/net/websocket_client.c b/src/net/websocket_client.c
new file mode 100644
index 0000000..924c2ee
--- /dev/null
+++ b/src/net/websocket_client.c
@@ -0,0 +1,175 @@
+#include "websocket_client.h"
+
+#include
+#include
+#include
+#include
+
+#include "http_client.h"
+
+struct GoWebSocket {
+ CURL* handle;
+ struct curl_slist* headers;
+ char error[CURL_ERROR_SIZE];
+};
+
+static unsigned int from_curl_flags(int flags) {
+ unsigned int result = 0;
+ if (flags & CURLWS_TEXT)
+ result |= GO_WEBSOCKET_TEXT;
+ if (flags & CURLWS_BINARY)
+ result |= GO_WEBSOCKET_BINARY;
+ if (flags & CURLWS_CONT)
+ result |= GO_WEBSOCKET_CONTINUATION;
+ if (flags & CURLWS_CLOSE)
+ result |= GO_WEBSOCKET_CLOSE;
+ return result;
+}
+
+static int to_curl_flags(unsigned int flags, unsigned int* result) {
+ const unsigned int supported =
+ GO_WEBSOCKET_TEXT | GO_WEBSOCKET_BINARY | GO_WEBSOCKET_CONTINUATION | GO_WEBSOCKET_CLOSE;
+ if (!result || (flags & ~supported) != 0)
+ return -1;
+ *result = 0;
+ if (flags & GO_WEBSOCKET_TEXT)
+ *result |= CURLWS_TEXT;
+ if (flags & GO_WEBSOCKET_BINARY)
+ *result |= CURLWS_BINARY;
+ if (flags & GO_WEBSOCKET_CONTINUATION)
+ *result |= CURLWS_CONT;
+ if (flags & GO_WEBSOCKET_CLOSE)
+ *result |= CURLWS_CLOSE;
+ return 0;
+}
+
+static int valid_header_value(const char* value) {
+ return value && value[0] != '\0' && !strchr(value, '\r') && !strchr(value, '\n');
+}
+
+static int append_header(GoWebSocket* socket, const char* name, const char* value) {
+ char header[1024];
+ int length = snprintf(header, sizeof(header), "%s: %s", name, value);
+ if (length <= 0 || length >= (int)sizeof(header))
+ return -1;
+ struct curl_slist* next = curl_slist_append(socket->headers, header);
+ if (!next)
+ return -1;
+ socket->headers = next;
+ return 0;
+}
+
+static void copy_error(char* output, size_t capacity, const char* message) {
+ if (!output || capacity == 0)
+ return;
+ snprintf(output, capacity, "%s", message && message[0] ? message : "WebSocket request failed");
+}
+
+GoWebSocket* go_websocket_open(const char* url, const char* origin, const char* subprotocol,
+ const char* user_agent, char* error, size_t error_capacity) {
+ if (!valid_header_value(url) || !valid_header_value(origin) ||
+ !valid_header_value(subprotocol) || !valid_header_value(user_agent)) {
+ copy_error(error, error_capacity, "Invalid WebSocket connection parameters");
+ return NULL;
+ }
+
+ GoWebSocket* socket = calloc(1, sizeof(*socket));
+ if (!socket) {
+ copy_error(error, error_capacity, "WebSocket allocation failed");
+ return NULL;
+ }
+ socket->handle = curl_easy_init();
+ if (!socket->handle)
+ goto fail;
+ if (append_header(socket, "Origin", origin) != 0 ||
+ append_header(socket, "Sec-WebSocket-Protocol", subprotocol) != 0)
+ goto fail;
+
+ curl_easy_setopt(socket->handle, CURLOPT_URL, url);
+ curl_easy_setopt(socket->handle, CURLOPT_CONNECT_ONLY, 2L);
+ curl_easy_setopt(socket->handle, CURLOPT_HTTPHEADER, socket->headers);
+ curl_easy_setopt(socket->handle, CURLOPT_USERAGENT, user_agent);
+ curl_easy_setopt(socket->handle, CURLOPT_CONNECTTIMEOUT, 15L);
+ curl_easy_setopt(socket->handle, CURLOPT_TIMEOUT, 30L);
+ curl_easy_setopt(socket->handle, CURLOPT_NOSIGNAL, 1L);
+ curl_easy_setopt(socket->handle, CURLOPT_TCP_NODELAY, 1L);
+ curl_easy_setopt(socket->handle, CURLOPT_ERRORBUFFER, socket->error);
+ const char* ca_bundle = go_http_ca_bundle();
+ if (ca_bundle)
+ curl_easy_setopt(socket->handle, CURLOPT_CAINFO, ca_bundle);
+
+ CURLcode result = curl_easy_perform(socket->handle);
+ if (result != CURLE_OK) {
+ if (socket->error[0] == '\0')
+ snprintf(socket->error, sizeof(socket->error), "%s", curl_easy_strerror(result));
+ goto fail;
+ }
+ return socket;
+
+fail:
+ copy_error(error, error_capacity, socket->error);
+ if (socket->handle)
+ curl_easy_cleanup(socket->handle);
+ curl_slist_free_all(socket->headers);
+ free(socket);
+ return NULL;
+}
+
+int go_websocket_send(GoWebSocket* socket, const void* data, size_t length, unsigned int flags,
+ size_t* sent) {
+ if (!socket || !socket->handle || !sent || (length > 0 && !data))
+ return -1;
+ unsigned int curl_flags = 0;
+ if (to_curl_flags(flags, &curl_flags) != 0)
+ return -1;
+ *sent = 0;
+ CURLcode result = curl_ws_send(socket->handle, data, length, sent, 0, curl_flags);
+ if (result == CURLE_AGAIN)
+ return 0;
+ if (result != CURLE_OK) {
+ snprintf(socket->error, sizeof(socket->error), "%s", curl_easy_strerror(result));
+ return -1;
+ }
+ return 1;
+}
+
+int go_websocket_receive(GoWebSocket* socket, void* output, size_t capacity,
+ GoWebSocketFrame* frame) {
+ if (!socket || !socket->handle || !output || capacity == 0 || !frame)
+ return -1;
+ memset(frame, 0, sizeof(*frame));
+ size_t received = 0;
+ const struct curl_ws_frame* metadata = NULL;
+ CURLcode result = curl_ws_recv(socket->handle, output, capacity, &received, &metadata);
+ if (result == CURLE_AGAIN)
+ return 0;
+ if (result == CURLE_GOT_NOTHING)
+ return -2;
+ if (result != CURLE_OK || !metadata) {
+ snprintf(socket->error, sizeof(socket->error), "%s", curl_easy_strerror(result));
+ return -1;
+ }
+ frame->length = received;
+ frame->offset = metadata->offset;
+ frame->bytes_left = metadata->bytesleft;
+ frame->flags = from_curl_flags(metadata->flags);
+ return (metadata->flags & CURLWS_CLOSE) ? -2 : 1;
+}
+
+const char* go_websocket_last_error(const GoWebSocket* socket) {
+ if (!socket || socket->error[0] == '\0')
+ return "WebSocket request failed";
+ return socket->error;
+}
+
+void go_websocket_close(GoWebSocket* socket) {
+ if (!socket)
+ return;
+ if (socket->handle) {
+ size_t sent = 0;
+ (void)curl_ws_send(socket->handle, "", 0, &sent, 0, CURLWS_CLOSE);
+ curl_easy_cleanup(socket->handle);
+ }
+ curl_slist_free_all(socket->headers);
+ free(socket);
+}
diff --git a/src/net/websocket_client.h b/src/net/websocket_client.h
new file mode 100644
index 0000000..380386c
--- /dev/null
+++ b/src/net/websocket_client.h
@@ -0,0 +1,39 @@
+#ifndef GREENOVERCAST_WEBSOCKET_CLIENT_H
+#define GREENOVERCAST_WEBSOCKET_CLIENT_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct GoWebSocket GoWebSocket;
+
+typedef struct {
+ size_t length;
+ long long offset;
+ long long bytes_left;
+ unsigned int flags;
+} GoWebSocketFrame;
+
+enum {
+ GO_WEBSOCKET_TEXT = 1 << 0,
+ GO_WEBSOCKET_BINARY = 1 << 1,
+ GO_WEBSOCKET_CONTINUATION = 1 << 2,
+ GO_WEBSOCKET_CLOSE = 1 << 3,
+};
+
+GoWebSocket* go_websocket_open(const char* url, const char* origin, const char* subprotocol,
+ const char* user_agent, char* error, size_t error_capacity);
+int go_websocket_send(GoWebSocket* socket, const void* data, size_t length, unsigned int flags,
+ size_t* sent);
+int go_websocket_receive(GoWebSocket* socket, void* output, size_t capacity,
+ GoWebSocketFrame* frame);
+const char* go_websocket_last_error(const GoWebSocket* socket);
+void go_websocket_close(GoWebSocket* socket);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/src/net/xml_document.zig b/src/net/xml_document.zig
new file mode 100644
index 0000000..1d5c0d2
--- /dev/null
+++ b/src/net/xml_document.zig
@@ -0,0 +1,148 @@
+const std = @import("std");
+const c = @cImport({
+ @cInclude("expat.h");
+});
+
+const maximum_bytes = 1024 * 1024;
+const maximum_nodes = 8192;
+const maximum_depth = 32;
+
+pub const Node = struct {
+ name: []const u8,
+ parent: ?*Node,
+ attributes: std.StringHashMapUnmanaged([]const u8) = .{},
+ children: std.ArrayListUnmanaged(*Node) = .{},
+ text: std.ArrayListUnmanaged(u8) = .{},
+
+ pub fn child(self: *const Node, name: []const u8) ?*Node {
+ for (self.children.items) |entry| if (std.mem.eql(u8, entry.name, name)) return entry;
+ return null;
+ }
+
+ pub fn content(self: *const Node) []const u8 {
+ return std.mem.trim(u8, self.text.items, " \t\r\n");
+ }
+};
+
+pub const Document = struct {
+ arena: std.heap.ArenaAllocator,
+ root: *Node,
+
+ pub fn deinit(self: *Document) void {
+ self.arena.deinit();
+ }
+};
+
+const Builder = struct {
+ allocator: std.mem.Allocator,
+ parser: c.XML_Parser,
+ root: ?*Node = null,
+ current: ?*Node = null,
+ failure: ?anyerror = null,
+ depth: usize = 0,
+ nodes: usize = 0,
+ text_bytes: usize = 0,
+
+ fn fail(self: *Builder, err: anyerror) void {
+ self.failure = err;
+ _ = c.XML_StopParser(self.parser, c.XML_FALSE);
+ }
+
+ fn open(self: *Builder, name: []const u8, attributes: [*c]const [*c]const u8) !void {
+ if (self.depth == maximum_depth or self.nodes == maximum_nodes) return error.XmlLimitExceeded;
+ const node = try self.allocator.create(Node);
+ node.* = .{ .name = try self.allocator.dupe(u8, name), .parent = self.current };
+ var index: usize = 0;
+ while (attributes[index] != null) : (index += 2) {
+ if (index >= 128) return error.XmlLimitExceeded;
+ const key = try self.allocator.dupe(u8, std.mem.span(attributes[index]));
+ const value = try self.allocator.dupe(u8, std.mem.span(attributes[index + 1]));
+ try node.attributes.put(self.allocator, key, value);
+ }
+ if (self.current) |parent| {
+ try parent.children.append(self.allocator, node);
+ } else {
+ if (self.root != null) return error.InvalidXml;
+ self.root = node;
+ }
+ self.current = node;
+ self.depth += 1;
+ self.nodes += 1;
+ }
+};
+
+fn builder(context: ?*anyopaque) *Builder {
+ return @ptrCast(@alignCast(context.?));
+}
+
+fn start(context: ?*anyopaque, name: [*c]const u8, attributes: [*c]const [*c]const u8) callconv(.c) void {
+ const state = builder(context);
+ state.open(std.mem.span(name), attributes) catch |err| state.fail(err);
+}
+
+fn end(context: ?*anyopaque, _: [*c]const u8) callconv(.c) void {
+ const state = builder(context);
+ if (state.current) |current| state.current = current.parent;
+ if (state.depth > 0) state.depth -= 1;
+}
+
+fn text(context: ?*anyopaque, data: [*c]const u8, length: c_int) callconv(.c) void {
+ const state = builder(context);
+ const current = state.current orelse return;
+ const size: usize = @intCast(length);
+ if (size > maximum_bytes - state.text_bytes) {
+ state.fail(error.XmlLimitExceeded);
+ return;
+ }
+ state.text_bytes += size;
+ current.text.appendSlice(state.allocator, data[0..size]) catch |err| state.fail(err);
+}
+
+fn doctype(context: ?*anyopaque, _: [*c]const u8, _: [*c]const u8, _: [*c]const u8, _: c_int) callconv(.c) void {
+ builder(context).fail(error.XmlDoctypeForbidden);
+}
+
+pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Document {
+ if (data.len > maximum_bytes) return error.XmlLimitExceeded;
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ errdefer arena.deinit();
+ const parser = c.XML_ParserCreate(null) orelse return error.OutOfMemory;
+ defer c.XML_ParserFree(parser);
+ var state = Builder{ .allocator = arena.allocator(), .parser = parser };
+ c.XML_SetUserData(parser, &state);
+ c.XML_SetElementHandler(parser, start, end);
+ c.XML_SetCharacterDataHandler(parser, text);
+ c.XML_SetStartDoctypeDeclHandler(parser, doctype);
+ _ = c.XML_SetParamEntityParsing(parser, c.XML_PARAM_ENTITY_PARSING_NEVER);
+ const status = c.XML_Parse(parser, data.ptr, @intCast(data.len), c.XML_TRUE);
+ if (state.failure) |err| return err;
+ if (status != c.XML_STATUS_OK or state.root == null) return error.InvalidXml;
+ return .{ .arena = arena, .root = state.root.? };
+}
+
+test "empty VAST and CDATA/entity content are parsed, not searched as strings" {
+ var empty = try parse(std.testing.allocator, "");
+ defer empty.deinit();
+ try std.testing.expectEqualStrings("VAST", empty.root.name);
+ try std.testing.expectEqual(@as(usize, 0), empty.root.children.items.len);
+ var doc = try parse(std.testing.allocator, " https://example.invalid/?a=1&b=2");
+ defer doc.deinit();
+ const ad = doc.root.child("Ad").?;
+ try std.testing.expectEqualStrings("a&b", ad.attributes.get("id").?);
+ try std.testing.expectEqualStrings("https://example.invalid/?a=1&b=2&c=3", ad.child("Url").?.content());
+}
+
+test "malformed XML and external or expanding entities cannot be accepted" {
+ for ([_][]const u8{ "", "", "", "garbage", "", "&undefined;" }) |input|
+ try std.testing.expectError(error.InvalidXml, parse(std.testing.allocator, input));
+ for ([_][]const u8{ "", "]>&a;" }) |input|
+ try std.testing.expectError(error.XmlDoctypeForbidden, parse(std.testing.allocator, input));
+}
+
+test "input and nesting limits are enforced with allocator cleanup" {
+ const data = try std.testing.allocator.alloc(u8, maximum_bytes + 1);
+ defer std.testing.allocator.free(data);
+ try std.testing.expectError(error.XmlLimitExceeded, parse(std.testing.allocator, data));
+ const nested = "" ** (maximum_depth + 1) ++ "" ** (maximum_depth + 1);
+ try std.testing.expectError(error.XmlLimitExceeded, parse(std.testing.allocator, nested));
+}
diff --git a/src/provider/geforce_now/ad_network.zig b/src/provider/geforce_now/ad_network.zig
new file mode 100644
index 0000000..099a7cb
--- /dev/null
+++ b/src/provider/geforce_now/ad_network.zig
@@ -0,0 +1,432 @@
+const std = @import("std");
+const vast = @import("vast.zig");
+const protocol = @import("ad_protocol.zig");
+const c = @cImport({
+ @cInclude("curl/curl.h");
+ @cInclude("http_client.h");
+ @cInclude("stdlib.h");
+});
+
+pub const Reporter = struct {
+ context: *anyopaque,
+ send: *const fn (*anyopaque, protocol.Update) anyerror!void,
+};
+pub const Source = struct {
+ context: *anyopaque,
+ read: *const fn (*anyopaque, std.mem.Allocator, []const u8, usize, *std.atomic.Value(bool)) anyerror![]u8,
+};
+pub const Fetch = struct {
+ done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ result: ?[]u8 = null,
+ failure: ?anyerror = null,
+};
+const Job = union(enum) {
+ report: protocol.Update,
+ beacon: []u8,
+ fetch: struct { address: []const u8, limit: usize, output: *Fetch },
+};
+
+pub const Worker = struct {
+ allocator: std.mem.Allocator,
+ reporter: Reporter,
+ source: ?Source = null,
+ abort: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ threads: [2]?std.Thread = .{ null, null },
+ mutex: std.Thread.Mutex = .{},
+ wake: std.Thread.Condition = .{},
+ jobs: [256]Job = undefined,
+ count: usize = 0,
+ working: [2]bool = .{ false, false },
+ closing: bool = false,
+ failure: ?anyerror = null,
+ beacon_failures: usize = 0,
+
+ pub fn start(self: *Worker) !void {
+ if (self.threads[0] != null or self.closing) return error.AdWorkerStopped;
+ errdefer self.deinit();
+ for (&self.threads, 0..) |*thread, index| thread.* = try std.Thread.spawn(.{}, run, .{ self, index });
+ }
+
+ fn enqueue(self: *Worker, job: Job) !void {
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ if (self.closing or self.threads[1] == null) return error.AdWorkerStopped;
+ if (self.count == self.jobs.len) return error.AdReportQueueFull;
+ self.jobs[self.count] = job;
+ self.count += 1;
+ self.wake.broadcast();
+ }
+
+ pub fn report(self: *Worker, update: protocol.Update) !void {
+ try self.enqueue(.{ .report = update });
+ }
+ pub fn beacon(self: *Worker, address: []const u8) !void {
+ const owned = try self.allocator.dupe(u8, address);
+ errdefer self.allocator.free(owned);
+ try self.enqueue(.{ .beacon = owned });
+ }
+ pub fn fetch(self: *Worker, address: []const u8, limit: usize, output: *Fetch) !void {
+ try self.enqueue(.{ .fetch = .{ .address = address, .limit = limit, .output = output } });
+ }
+ pub fn idle(self: *Worker) !bool {
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ if (self.failure) |failure| return failure;
+ return self.count == 0 and !self.working[0] and !self.working[1];
+ }
+
+ pub fn reportsIdle(self: *Worker) !bool {
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ if (self.failure) |failure| return failure;
+ if (self.working[0]) return false;
+ for (self.jobs[0..self.count]) |job| if (job == .report) return false;
+ return true;
+ }
+
+ fn nextJob(self: *Worker, index: usize) ?Job {
+ var found: ?usize = null;
+ for (self.jobs[0..self.count], 0..) |job, position| {
+ if (index == 0 and job == .report) {
+ found = position;
+ break;
+ }
+ if (index == 1 and job != .report) {
+ if (found == null) found = position;
+ if (job == .fetch) {
+ found = position;
+ break;
+ }
+ }
+ }
+ const position = found orelse return null;
+ const job = self.jobs[position];
+ std.mem.copyForwards(Job, self.jobs[position .. self.count - 1], self.jobs[position + 1 .. self.count]);
+ self.count -= 1;
+ return job;
+ }
+
+ fn run(self: *Worker, index: usize) void {
+ while (true) {
+ self.mutex.lock();
+ const job = while (true) {
+ if (self.nextJob(index)) |job| break job;
+ if (self.closing) {
+ self.mutex.unlock();
+ return;
+ }
+ self.wake.wait(&self.mutex);
+ };
+ self.working[index] = true;
+ self.mutex.unlock();
+ switch (job) {
+ .report => |update| if (!self.abort.load(.acquire)) self.reporter.send(self.reporter.context, update) catch |err| {
+ self.mutex.lock();
+ if (self.failure == null) self.failure = err;
+ self.mutex.unlock();
+ self.abort.store(true, .release);
+ },
+ .beacon => |address| {
+ defer self.allocator.free(address);
+ const bytes = self.download(address, 64 * 1024, 3) catch |err| {
+ if (err != error.Cancelled) self.beacon_failures += 1;
+ self.finishJob(index);
+ continue;
+ };
+ self.allocator.free(bytes);
+ },
+ .fetch => |request| {
+ request.output.result = self.download(request.address, request.limit, 30) catch |err| result: {
+ request.output.failure = err;
+ break :result null;
+ };
+ request.output.done.store(true, .release);
+ },
+ }
+ self.finishJob(index);
+ }
+ }
+ fn download(self: *Worker, address: []const u8, limit: usize, timeout_seconds: c_long) ![]u8 {
+ try vast.validateUrl(address);
+ if (self.abort.load(.acquire)) return error.Cancelled;
+ if (self.source) |source| {
+ const bytes = try source.read(source.context, self.allocator, address, limit, &self.abort);
+ if (bytes.len > limit) {
+ self.allocator.free(bytes);
+ return error.AdResponseTooLarge;
+ }
+ return bytes;
+ }
+ return get(self.allocator, address, limit, &self.abort, timeout_seconds);
+ }
+ fn finishJob(self: *Worker, index: usize) void {
+ self.mutex.lock();
+ self.working[index] = false;
+ self.mutex.unlock();
+ }
+ pub fn deinit(self: *Worker) void {
+ self.abort.store(true, .release);
+ self.mutex.lock();
+ self.closing = true;
+ self.wake.broadcast();
+ self.mutex.unlock();
+ for (&self.threads) |*thread| {
+ if (thread.*) |running| running.join();
+ thread.* = null;
+ }
+ if (self.beacon_failures > 0) std.debug.print("GeForce NOW ad tracking failures: {d}\n", .{self.beacon_failures});
+ self.beacon_failures = 0;
+ }
+};
+
+const Transfer = struct {
+ bytes: std.ArrayList(u8),
+ location: ?[]u8 = null,
+ limit: usize,
+ abort: *std.atomic.Value(bool),
+ failure: ?anyerror = null,
+};
+fn body(data: [*]const u8, size: usize, count: usize, context: ?*anyopaque) callconv(.c) usize {
+ const transfer: *Transfer = @ptrCast(@alignCast(context.?));
+ const length = std.math.mul(usize, size, count) catch return 0;
+ if (length > transfer.limit - transfer.bytes.items.len) {
+ transfer.failure = error.AdResponseTooLarge;
+ return 0;
+ }
+ transfer.bytes.appendSlice(data[0..length]) catch |err| {
+ transfer.failure = err;
+ return 0;
+ };
+ return length;
+}
+fn header(data: [*]const u8, size: usize, count: usize, context: ?*anyopaque) callconv(.c) usize {
+ const transfer: *Transfer = @ptrCast(@alignCast(context.?));
+ const length = std.math.mul(usize, size, count) catch return 0;
+ const line = data[0..length];
+ if (std.ascii.startsWithIgnoreCase(line, "Location:")) {
+ const address = std.mem.trim(u8, line[9..], " \t\r\n");
+ if (address.len > 8192) return 0;
+ if (transfer.location) |previous| transfer.bytes.allocator.free(previous);
+ transfer.location = transfer.bytes.allocator.dupe(u8, address) catch return 0;
+ }
+ return length;
+}
+fn cancelled(context: ?*anyopaque, _: c.curl_off_t, _: c.curl_off_t, _: c.curl_off_t, _: c.curl_off_t) callconv(.c) c_int {
+ const transfer: *Transfer = @ptrCast(@alignCast(context.?));
+ return @intFromBool(transfer.abort.load(.acquire));
+}
+fn connected(_: ?*anyopaque, remote: [*c]const u8, _: [*c]const u8, _: c_int, _: c_int) callconv(.c) c_int {
+ return if (publicIp(std.mem.span(remote))) c.CURL_PREREQFUNC_OK else c.CURL_PREREQFUNC_ABORT;
+}
+
+fn publicIp(address: []const u8) bool {
+ const parsed = std.net.Address.parseIp(address, 443) catch return false;
+ if (parsed.any.family == std.posix.AF.INET) {
+ const bytes = std.mem.asBytes(&parsed.in.sa.addr);
+ return bytes[0] != 0 and bytes[0] != 10 and bytes[0] != 127 and bytes[0] < 224 and
+ !(bytes[0] == 169 and bytes[1] == 254) and !(bytes[0] == 172 and bytes[1] >= 16 and bytes[1] <= 31) and
+ !(bytes[0] == 192 and bytes[1] == 168) and !(bytes[0] == 100 and bytes[1] >= 64 and bytes[1] <= 127) and
+ !(bytes[0] == 198 and (bytes[1] == 18 or bytes[1] == 19));
+ }
+ // Only global unicast IPv6; excludes loopback, link-local and mapped IPv4.
+ return parsed.in6.sa.addr[0] & 0xe0 == 0x20;
+}
+
+fn get(allocator: std.mem.Allocator, address: []const u8, limit: usize, abort: *std.atomic.Value(bool), timeout_seconds: c_long) ![]u8 {
+ var current = try allocator.dupeZ(u8, address);
+ defer allocator.free(current);
+ for (0..6) |_| {
+ if (abort.load(.acquire)) return error.Cancelled;
+ try vast.validateUrl(current);
+ var transfer = Transfer{ .bytes = std.ArrayList(u8).init(allocator), .limit = limit, .abort = abort };
+ defer transfer.bytes.deinit();
+ defer if (transfer.location) |location| allocator.free(location);
+ const handle = c.curl_easy_init() orelse return error.HttpUnavailable;
+ defer c.curl_easy_cleanup(handle);
+ if (c.curl_easy_setopt(handle, c.CURLOPT_URL, current.ptr) != c.CURLE_OK or
+ // The peer-address check must inspect the origin, not a proxy.
+ c.curl_easy_setopt(handle, c.CURLOPT_PROXY, @as([*:0]const u8, "")) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_PROTOCOLS_STR, @as([*:0]const u8, "https")) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_USERAGENT, @as([*:0]const u8, "GreenOvercast")) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_TIMEOUT, timeout_seconds) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_CONNECTTIMEOUT, @min(@as(c_long, 10), timeout_seconds)) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_NOSIGNAL, @as(c_long, 1)) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_WRITEFUNCTION, body) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_WRITEDATA, &transfer) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_HEADERFUNCTION, header) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_HEADERDATA, &transfer) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_NOPROGRESS, @as(c_long, 0)) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_XFERINFOFUNCTION, cancelled) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_XFERINFODATA, &transfer) != c.CURLE_OK or
+ c.curl_easy_setopt(handle, c.CURLOPT_PREREQFUNCTION, connected) != c.CURLE_OK)
+ return error.HttpConfigurationFailed;
+ const ca = c.go_http_ca_bundle();
+ if (ca != null and c.curl_easy_setopt(handle, c.CURLOPT_CAINFO, ca) != c.CURLE_OK)
+ return error.HttpConfigurationFailed;
+ const result = c.curl_easy_perform(handle);
+ if (abort.load(.acquire)) return error.Cancelled;
+ if (transfer.failure) |failure| return failure;
+ if (result != c.CURLE_OK) return error.AdDownloadFailed;
+ var status: c_long = 0;
+ if (c.curl_easy_getinfo(handle, c.CURLINFO_RESPONSE_CODE, &status) != c.CURLE_OK) return error.HttpStatusUnavailable;
+ if (status >= 200 and status < 300) return transfer.bytes.toOwnedSlice();
+ if (status < 300 or status >= 400) return error.AdDownloadRejected;
+ const location = transfer.location orelse return error.MissingRedirect;
+ // Redirects never carry account headers, cookies or shared curl state.
+ const next = try redirect(allocator, current, location);
+ allocator.free(current);
+ current = next;
+ }
+ return error.AdRedirectLimit;
+}
+
+fn redirect(allocator: std.mem.Allocator, base: []const u8, location: []const u8) ![:0]u8 {
+ if (location.len == 0 or location.len > 8192) return error.InvalidAdUrl;
+ const buffer = try allocator.alloc(u8, 32768);
+ defer allocator.free(buffer);
+ var available = buffer;
+ const resolved = try std.Uri.resolve_inplace(try std.Uri.parse(base), location, &available);
+ const address = try std.fmt.allocPrintZ(allocator, "{}", .{resolved});
+ errdefer allocator.free(address);
+ try vast.validateUrl(address);
+ return address;
+}
+
+test "relative redirects are resolved without allowing credentials or HTTP" {
+ const address = try redirect(std.testing.allocator, "https://example.invalid/path/tag", "../media?key=one");
+ defer std.testing.allocator.free(address);
+ try std.testing.expectEqualStrings("https://example.invalid/media?key=one", address);
+ try std.testing.expectError(error.InvalidAdUrl, redirect(std.testing.allocator, "https://example.invalid/", "http://example.invalid/media"));
+ try std.testing.expectError(error.InvalidAdUrl, redirect(std.testing.allocator, "https://example.invalid/", "https://user:pass@example.invalid/media"));
+}
+
+test "ad connections reject local networks including IPv4 mapped IPv6" {
+ for ([_][]const u8{ "127.0.0.1", "10.0.0.1", "169.254.1.1", "172.16.0.1", "192.168.1.1", "::1", "::ffff:127.0.0.1", "fe80::1", "fc00::1" }) |value|
+ try std.testing.expect(!publicIp(value));
+ try std.testing.expect(publicIp("8.8.8.8"));
+ try std.testing.expect(publicIp("2001:4860:4860::8888"));
+}
+
+test "ad transfers do not delegate destination resolution to environment proxies" {
+ const names = [_][:0]const u8{ "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY", "no_proxy", "NO_PROXY" };
+ var saved = [_]?[:0]u8{null} ** names.len;
+ defer for (names, saved) |name, value| {
+ if (value) |original| {
+ _ = c.setenv(name.ptr, original.ptr, 1);
+ std.testing.allocator.free(original);
+ } else _ = c.unsetenv(name.ptr);
+ };
+ for (names, &saved) |name, *value| {
+ if (c.getenv(name.ptr)) |original| value.* = try std.testing.allocator.dupeZ(u8, std.mem.span(original));
+ }
+ for (names) |name| try std.testing.expectEqual(@as(c_int, 0), c.unsetenv(name.ptr));
+ var trap = try (try std.net.Address.parseIp("127.0.0.1", 0)).listen(.{ .force_nonblocking = true });
+ defer trap.deinit();
+ const proxy = try std.fmt.allocPrintZ(std.testing.allocator, "http://127.0.0.1:{d}", .{trap.listen_address.getPort()});
+ defer std.testing.allocator.free(proxy);
+ var abort = std.atomic.Value(bool).init(false);
+ for (names[0..4]) |name| {
+ try std.testing.expectEqual(@as(c_int, 0), c.setenv(name.ptr, proxy.ptr, 1));
+ try std.testing.expectError(error.AdDownloadFailed, get(std.testing.allocator, "https://ad-proxy-test.invalid/", 1024, &abort, 1));
+ if (trap.accept()) |connection| {
+ connection.stream.close();
+ return error.AdTransferUsedProxy;
+ } else |err| try std.testing.expectEqual(error.WouldBlock, err);
+ try std.testing.expectEqual(@as(c_int, 0), c.unsetenv(name.ptr));
+ }
+}
+
+test "report queue is bounded and stop joins an in-flight fetch" {
+ const Fake = struct {
+ entered: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ release: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ stop: *std.atomic.Value(bool),
+ received: usize = 0,
+
+ fn send(context: *anyopaque, _: protocol.Update) !void {
+ const self: *@This() = @ptrCast(@alignCast(context));
+ self.entered.store(true, .release);
+ while (!self.release.load(.acquire) and !self.stop.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms);
+ self.received += 1;
+ }
+ fn read(context: *anyopaque, _: std.mem.Allocator, _: []const u8, _: usize, stop: *std.atomic.Value(bool)) ![]u8 {
+ const self: *@This() = @ptrCast(@alignCast(context));
+ self.entered.store(true, .release);
+ while (!stop.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms);
+ return error.Cancelled;
+ }
+ fn wait(self: *@This()) !void {
+ for (0..2000) |_| {
+ if (self.entered.load(.acquire)) return;
+ std.Thread.sleep(std.time.ns_per_ms);
+ }
+ return error.TestTimedOut;
+ }
+ };
+ var stop = std.atomic.Value(bool).init(false);
+ var fake = Fake{ .stop = &stop };
+ var worker = Worker{ .allocator = std.testing.allocator, .reporter = .{ .context = &fake, .send = Fake.send } };
+ fake.stop = &worker.abort;
+ try worker.start();
+ defer worker.deinit();
+ try worker.report(.{ .action = .start });
+ try fake.wait();
+ for (0..256) |_| try worker.report(.{ .action = .event, .event = "test" });
+ try std.testing.expectError(error.AdReportQueueFull, worker.report(.{ .action = .finish }));
+ fake.release.store(true, .release);
+ for (0..2000) |_| {
+ if (try worker.idle()) break;
+ std.Thread.sleep(std.time.ns_per_ms);
+ }
+ try std.testing.expect(try worker.idle());
+ worker.deinit();
+ try std.testing.expectEqual(@as(usize, 257), fake.received);
+ try std.testing.expectError(error.AdWorkerStopped, worker.report(.{ .action = .finish }));
+
+ fake.entered.store(false, .release);
+ var fetch_worker = Worker{ .allocator = std.testing.allocator, .reporter = .{ .context = &fake, .send = Fake.send }, .source = .{ .context = &fake, .read = Fake.read } };
+ try fetch_worker.start();
+ defer fetch_worker.deinit();
+ var result = Fetch{};
+ try fetch_worker.fetch("https://example.invalid", 1024, &result);
+ try fake.wait();
+ fetch_worker.deinit();
+ try std.testing.expect(result.done.load(.acquire));
+ try std.testing.expect(result.failure.? == error.Cancelled and result.result == null);
+}
+
+test "slow advertiser tracking cannot block session reports" {
+ const Fake = struct {
+ entered: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ reported: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ fn read(context: *anyopaque, _: std.mem.Allocator, _: []const u8, _: usize, stop: *std.atomic.Value(bool)) ![]u8 {
+ const self: *@This() = @ptrCast(@alignCast(context));
+ self.entered.store(true, .release);
+ while (!stop.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms);
+ return error.Cancelled;
+ }
+ fn send(context: *anyopaque, _: protocol.Update) !void {
+ const self: *@This() = @ptrCast(@alignCast(context));
+ self.reported.store(true, .release);
+ }
+ };
+ var fake = Fake{};
+ var worker = Worker{ .allocator = std.testing.allocator, .reporter = .{ .context = &fake, .send = Fake.send }, .source = .{ .context = &fake, .read = Fake.read } };
+ try worker.start();
+ defer worker.deinit();
+ try worker.beacon("https://example.invalid/slow-tracker");
+ for (0..2000) |_| {
+ if (fake.entered.load(.acquire)) break;
+ std.Thread.sleep(std.time.ns_per_ms);
+ }
+ try std.testing.expect(fake.entered.load(.acquire));
+ try worker.report(.{ .action = .finish });
+ for (0..2000) |_| {
+ if (fake.reported.load(.acquire)) break;
+ std.Thread.sleep(std.time.ns_per_ms);
+ }
+ try std.testing.expect(fake.reported.load(.acquire));
+ try std.testing.expect(!try worker.idle());
+ worker.deinit();
+ try std.testing.expect(try worker.reportsIdle());
+}
diff --git a/src/provider/geforce_now/ad_playback.zig b/src/provider/geforce_now/ad_playback.zig
new file mode 100644
index 0000000..212fa8c
--- /dev/null
+++ b/src/provider/geforce_now/ad_playback.zig
@@ -0,0 +1,382 @@
+const std = @import("std");
+const session = @import("session_client.zig");
+pub const protocol = @import("ad_protocol.zig");
+const vast = @import("vast.zig");
+pub const network = @import("ad_network.zig");
+const clip = @import("clip_player");
+const policy = @import("ad_policy.zig");
+const c = @cImport({
+ @cInclude("handheld_ui.h");
+ @cInclude("artwork_decoder.h");
+});
+
+const Metadata = struct {
+ impressions: []const []const u8 = &.{},
+ errors: []const []const u8 = &.{},
+ tracks: []const vast.Track = &.{},
+ icons: []const vast.Icon = &.{},
+};
+const Resolved = struct { creative: vast.Creative, metadata: Metadata };
+const Parent = struct { metadata: Metadata, follow_wrappers: bool, allow_multiple: bool };
+const ImageTexture = struct { handle: *c.SDL_Texture, width: u16, height: u16 };
+const IconTexture = struct { image: ImageTexture, fallback: ImageTexture, icon: vast.Icon, viewed: bool = false };
+
+pub const Playback = struct {
+ renderer: *c.SDL_Renderer,
+ device: c.SDL_AudioDeviceID,
+ ui: *c.GoHandheldUi,
+ allocator: std.mem.Allocator = std.heap.c_allocator,
+ client: *session.Client = undefined,
+ opportunity: protocol.Opportunity = undefined,
+ provider: u8 = 0,
+ app_id: []const u8 = "",
+ watched_ms: u64 = 0,
+ paused_ms: u64 = 0,
+ duration_ms: u64 = 0,
+
+ pub fn handle(context: *anyopaque, client: *session.Client, opportunity: protocol.Opportunity, provider: u8) !void {
+ const self: *Playback = @ptrCast(@alignCast(context));
+ self.client = client;
+ self.opportunity = opportunity;
+ self.provider = provider;
+ self.app_id = client.app_id orelse "";
+ self.watched_ms = 0;
+ self.paused_ms = 0;
+ self.duration_ms = 0;
+ var worker = network.Worker{ .allocator = self.allocator, .reporter = .{ .context = self, .send = report } };
+ client.ad_stop = &worker.abort;
+ defer client.ad_stop = null;
+ try worker.start();
+ defer worker.deinit();
+ try self.execute(&worker);
+ }
+
+ pub fn execute(self: *Playback, worker: *network.Worker) !void {
+ self.run(worker) catch |err| {
+ worker.abort.store(true, .release);
+ worker.deinit();
+ worker.abort.store(false, .release);
+ const no_fill = err == error.VastEmptyResponse or err == error.VastNoAdsAfterWrapper;
+ const reason = if (err == error.VastEmptyResponse) "VAST_EMPTY_RESPONSE" else if (err == error.VastNoAdsAfterWrapper)
+ "VAST_NO_ADS_AFTER_WRAPPER"
+ else if (err == error.Cancelled) "USER_CANCELLED" else @errorName(err);
+ worker.reporter.send(worker.reporter.context, .{ .action = .cancel, .reason = reason, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms }) catch |report_error| {
+ std.debug.print("GeForce NOW ad cancellation failed: {s} after {s}\n", .{ @errorName(report_error), reason });
+ return if (no_fill) report_error else err;
+ };
+ std.debug.print("GeForce NOW ad: {s}, watched {d}ms\n", .{ reason, self.watched_ms });
+ if (!no_fill) return err;
+ };
+ }
+
+ fn report(context: *anyopaque, update: protocol.Update) !void {
+ const self: *Playback = @ptrCast(@alignCast(context));
+ try self.client.reportAd(self.opportunity.id, update);
+ }
+
+ fn fetch(self: *Playback, worker: *network.Worker, url: []const u8, limit: usize) ![]u8 {
+ var task = network.Fetch{};
+ try worker.fetch(url, limit, &task);
+ var was_cancelled = false;
+ while (!task.done.load(.acquire)) {
+ if (c.go_handheld_ui_cancel_requested(self.ui) != 0) {
+ worker.abort.store(true, .release);
+ was_cancelled = true;
+ }
+ c.SDL_Delay(16);
+ }
+ if (was_cancelled) {
+ if (task.result) |bytes| self.allocator.free(bytes);
+ return error.Cancelled;
+ }
+ if (task.failure) |failure| return failure;
+ return task.result orelse error.AdDownloadFailed;
+ }
+
+ fn drain(self: *Playback, worker: *network.Worker) !void {
+ const started = c.SDL_GetTicks64();
+ while (!try worker.idle()) {
+ if (c.go_handheld_ui_cancel_requested(self.ui) != 0) return error.Cancelled;
+ const elapsed = c.SDL_GetTicks64() - started;
+ if (elapsed >= 10000 and try worker.reportsIdle()) {
+ return;
+ }
+ if (elapsed >= 30000) return error.AdReportTimeout;
+ c.SDL_Delay(16);
+ }
+ }
+
+ fn drainAfterFailure(self: *Playback, worker: *network.Worker) !void {
+ self.drain(worker) catch |err| {
+ if (err == error.Cancelled) return err;
+ };
+ }
+
+ fn run(self: *Playback, worker: *network.Worker) !void {
+ c.go_handheld_ui_draw_loading(self.ui, "GEFORCE NOW", "LOADING ADVERTISEMENT", c.GO_HANDHELD_UI_ACTION_CANCEL);
+ if (self.provider > 1) return error.UnsupportedAdProvider;
+ var arena = std.heap.ArenaAllocator.init(self.allocator);
+ defer arena.deinit();
+ const storage = arena.allocator();
+ var documents = std.ArrayList(vast.Manifest).init(self.allocator);
+ defer {
+ for (documents.items) |*document| document.deinit();
+ documents.deinit();
+ }
+ var pod = std.ArrayList(Resolved).init(storage);
+ if (self.provider == 1) {
+ const target = try policy.tagUrl(storage, self.opportunity.url, self.app_id);
+ try self.load(worker, &documents, &pod, storage, target, null, 0);
+ } else {
+ try vast.validateUrl(self.opportunity.url);
+ try pod.append(.{ .metadata = .{}, .creative = .{ .duration_ms = self.opportunity.duration_ms, .media = try storage.dupe(vast.Media, &.{.{ .url = self.opportunity.url, .width = 640, .height = 360, .bitrate = 0 }}), .tracks = &.{}, .icons = &.{} } });
+ }
+ var length: u64 = 0;
+ for (pod.items) |ad| length += ad.creative.duration_ms;
+ if (length > 120000) return error.AdPodTooLong;
+ self.duration_ms = length;
+ for (pod.items, 0..) |ad, index| {
+ const prior = self.watched_ms;
+ self.play(worker, ad, index == 0) catch |err| {
+ if (err != error.Cancelled) for (ad.metadata.errors) |address| {
+ beacon(worker, storage, address, self.watched_ms - prior, policy.errorCode(err)) catch {};
+ };
+ try self.drainAfterFailure(worker);
+ return err;
+ };
+ }
+ try self.event(worker, "ALL_ADS_COMPLETED");
+ try worker.report(.{ .action = .finish, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms });
+ try self.drain(worker);
+ std.debug.print("GeForce NOW ad completed: watched {d}ms\n", .{self.watched_ms});
+ }
+
+ fn load(self: *Playback, worker: *network.Worker, documents: *std.ArrayList(vast.Manifest), pod: *std.ArrayList(Resolved), storage: std.mem.Allocator, url: []const u8, parent: ?Parent, depth: usize) !void {
+ if (depth > 5 or documents.items.len == 32) {
+ try self.wrapperError(worker, storage, parent, 302);
+ return error.VastWrapperLimit;
+ }
+ const bytes = self.fetch(worker, url, 1024 * 1024) catch |err| {
+ if (err != error.Cancelled) try self.wrapperError(worker, storage, parent, policy.errorCode(err));
+ return err;
+ };
+ defer self.allocator.free(bytes);
+ var document = vast.parse(self.allocator, bytes) catch |err| {
+ try self.wrapperError(worker, storage, parent, policy.errorCode(err));
+ return err;
+ };
+ documents.append(document) catch |err| {
+ document.deinit();
+ return err;
+ };
+ if (document.ads.len == 0) {
+ for (document.errors) |address| try beacon(worker, storage, address, 0, 303);
+ if (parent) |previous| for (previous.metadata.errors) |address| try beacon(worker, storage, address, 0, 303);
+ try self.drain(worker);
+ return if (depth == 0) error.VastEmptyResponse else error.VastNoAdsAfterWrapper;
+ }
+ const count_before = pod.items.len;
+ const candidates = try policy.selectAds(storage, document.ads, if (parent) |previous| previous.allow_multiple else true, if (parent) |previous| previous.follow_wrappers else true);
+ var used_fallbacks = [_]bool{false} ** 16;
+ for (candidates) |ad| {
+ for (ad.verification_errors) |address| try worker.beacon(try policy.expand(storage, address, 0, 0, 2));
+ const seed = if (parent) |previous| previous.metadata else Metadata{};
+ const metadata = Metadata{ .impressions = try policy.merge([]const u8, storage, seed.impressions, ad.impressions), .errors = try policy.merge([]const u8, storage, seed.errors, ad.errors), .tracks = try policy.merge(vast.Track, storage, seed.tracks, ad.tracks), .icons = try policy.merge(vast.Icon, storage, seed.icons, ad.icons) };
+ switch (ad.body) {
+ .wrapper => |wrapper| self.load(worker, documents, pod, storage, wrapper.url, .{ .metadata = metadata, .follow_wrappers = wrapper.follow_wrappers, .allow_multiple = wrapper.allow_multiple }, depth + 1) catch |err| {
+ if (err != error.VastNoAdsAfterWrapper) return err;
+ if (wrapper.fallback_on_no_ad) {
+ for (document.ads, 0..) |fallback, index| {
+ if (used_fallbacks[index] or fallback.sequence != null or fallback.body != .inline_ad) continue;
+ for (fallback.verification_errors) |address| try worker.beacon(try policy.expand(storage, address, 0, 0, 2));
+ if (pod.items.len == 16) return error.AdPodTooLarge;
+ try pod.append(try resolveInline(storage, seed, fallback));
+ used_fallbacks[index] = true;
+ break;
+ }
+ }
+ },
+ .inline_ad => {
+ if (pod.items.len == 16) return error.AdPodTooLarge;
+ try pod.append(try resolveInline(storage, seed, ad));
+ },
+ }
+ }
+ if (pod.items.len == count_before) return error.VastNoAdsAfterWrapper;
+ }
+
+ fn wrapperError(self: *Playback, worker: *network.Worker, storage: std.mem.Allocator, parent: ?Parent, code: u16) !void {
+ if (parent) |previous| {
+ for (previous.metadata.errors) |address| beacon(worker, storage, address, 0, code) catch {};
+ try self.drainAfterFailure(worker);
+ }
+ }
+
+ fn play(self: *Playback, worker: *network.Worker, ad: Resolved, first: bool) !void {
+ var icons = std.ArrayList(IconTexture).init(self.allocator);
+ defer {
+ for (icons.items) |icon| {
+ c.SDL_DestroyTexture(icon.image.handle);
+ c.SDL_DestroyTexture(icon.fallback.handle);
+ }
+ icons.deinit();
+ }
+ for (ad.metadata.icons) |icon| {
+ const fallback_url = icon.fallback orelse return error.UnsupportedAdChoices;
+ const image = try self.loadTexture(worker, icon.image, 640, 480);
+ errdefer c.SDL_DestroyTexture(image.handle);
+ const fallback = try self.loadTexture(worker, fallback_url, vast.max_fallback_width, vast.max_fallback_height);
+ errdefer c.SDL_DestroyTexture(fallback.handle);
+ try icons.append(.{ .image = image, .fallback = fallback, .icon = icon });
+ }
+ const media = policy.selectMedia(ad.creative.media) orelse return error.UnsupportedMedia;
+ const bytes = try self.fetch(worker, media.url, 96 * 1024 * 1024);
+ defer self.allocator.free(bytes);
+ var player = clip.Player{ .bytes = bytes, .device = self.device, .renderer = @ptrCast(self.renderer) };
+ defer player.deinit();
+ try player.start();
+ try self.event(worker, "LOADED");
+ const fired = try self.allocator.alloc(bool, ad.metadata.tracks.len);
+ defer self.allocator.free(fired);
+ @memset(fired, false);
+ const prior = self.watched_ms;
+ const prior_paused = self.paused_ms;
+ var started = false;
+ var modal = false;
+ var selected: usize = 0;
+ var last_progress = c.SDL_GetTicks64();
+ var last_position: u64 = 0;
+ var quartiles = [_]bool{false} ** 3;
+ const started_at = last_progress;
+ while (true) {
+ const action = c.go_handheld_ui_ad_action(self.ui);
+ if (action < 0 or (action == 2 and !modal)) return error.Cancelled;
+ if (action == 2 and modal) {
+ modal = false;
+ player.pause(false);
+ last_progress = c.SDL_GetTicks64();
+ self.paused_ms = prior_paused + player.paused_ms;
+ for (ad.metadata.tracks) |track| if (std.mem.eql(u8, track.event, "resume"))
+ try beacon(worker, self.allocator, track.url, last_position, 0);
+ try worker.report(.{ .action = .resume_playback, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms });
+ }
+ const progress = try player.tick();
+ self.watched_ms = prior + progress.played_ms;
+ self.paused_ms = prior_paused + player.paused_ms + if (player.paused_at) |paused| c.SDL_GetTicks64() - paused else 0;
+ if (progress.finished and progress.played_ms + 250 < ad.creative.duration_ms) return error.AdMediaTruncated;
+ if (progress.played_ms > last_position) {
+ last_progress = c.SDL_GetTicks64();
+ last_position = progress.played_ms;
+ }
+ if ((!modal and c.SDL_GetTicks64() - last_progress > 10000) or c.SDL_GetTicks64() - started_at > 180000)
+ return error.AdPlaybackStalled;
+ if (progress.rendered_frames > 0) {
+ try player.draw();
+ const size = try clip.canvasSize(@ptrCast(self.renderer));
+ if (icons.items.len > 0) {
+ if (action == 3 and !modal) selected = (selected + icons.items.len - 1) % icons.items.len;
+ if (action == 4 and !modal) selected = (selected + 1) % icons.items.len;
+ }
+ for (icons.items, 0..) |*icon, index| {
+ if (!policy.iconVisible(icon.icon, progress.played_ms)) continue;
+ const rect = c.SDL_Rect{ .x = policy.iconPosition(icon.icon.x, size.width, icon.icon.width), .y = policy.iconPosition(icon.icon.y, size.height, icon.icon.height), .w = icon.icon.width, .h = icon.icon.height };
+ if (c.SDL_RenderCopy(self.renderer, icon.image.handle, null, &rect) != 0) return error.AdIconRenderFailed;
+ if (index == selected and icons.items.len > 1 and !modal) {
+ const outline = c.SDL_Rect{ .x = rect.x - 1, .y = rect.y - 1, .w = rect.w + 2, .h = rect.h + 2 };
+ _ = c.SDL_SetRenderDrawColor(self.renderer, 255, 255, 255, 255);
+ if (c.SDL_RenderDrawRect(self.renderer, &outline) != 0) return error.AdIconRenderFailed;
+ }
+ if (action == 1 and index == selected and !modal and started) {
+ modal = true;
+ player.pause(true);
+ for (ad.metadata.tracks) |track| if (std.mem.eql(u8, track.event, "pause"))
+ try beacon(worker, self.allocator, track.url, progress.played_ms, 0);
+ for (icon.icon.clicks) |url| try beacon(worker, self.allocator, url, progress.played_ms, 0);
+ try worker.report(.{ .action = .pause, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms });
+ }
+ }
+ if (icons.items.len > 0) {
+ if (modal) {
+ _ = c.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 255);
+ _ = c.SDL_RenderClear(self.renderer);
+ const icon = icons.items[selected];
+ const fitted = try policy.fitImage(icon.fallback.width, icon.fallback.height, size.width, size.height - 40);
+ const rect = c.SDL_Rect{ .x = @divTrunc(size.width - fitted.width, 2), .y = @divTrunc(size.height - 40 - fitted.height, 2), .w = fitted.width, .h = fitted.height };
+ if (c.SDL_RenderCopy(self.renderer, icon.fallback.handle, null, &rect) != 0) return error.AdIconRenderFailed;
+ }
+ var hint_unobscured = player.texture_height * 640 <= player.texture_width * 400;
+ for (icons.items) |icon| {
+ const y = policy.iconPosition(icon.icon.y, size.height, icon.icon.height);
+ if (policy.iconVisible(icon.icon, progress.played_ms) and y < 465 and y + icon.icon.height > 435)
+ hint_unobscured = false;
+ }
+ if (modal or hint_unobscured) c.go_handheld_ui_draw_ad_hint(self.ui, @intFromBool(modal));
+ }
+ c.SDL_RenderPresent(self.renderer);
+ if (!modal) {
+ if (!started) {
+ started = true;
+ if (first) try worker.report(.{ .action = .start, .duration_ms = self.duration_ms });
+ for (ad.metadata.impressions) |url| try beacon(worker, self.allocator, url, progress.played_ms, 0);
+ try self.event(worker, "STARTED");
+ try self.event(worker, "IMPRESSION");
+ }
+ for (icons.items) |*icon| {
+ if (!icon.viewed and policy.iconVisible(icon.icon, progress.played_ms)) {
+ for (icon.icon.views) |url| try beacon(worker, self.allocator, url, progress.played_ms, 0);
+ icon.viewed = true;
+ }
+ }
+ for (ad.metadata.tracks, fired) |track, *sent| {
+ if (sent.* or !policy.due(track, progress.played_ms, ad.creative.duration_ms, progress.finished)) continue;
+ try beacon(worker, self.allocator, track.url, progress.played_ms, 0);
+ sent.* = true;
+ }
+ for (&quartiles, 0..) |*sent, index| {
+ if (sent.* or ad.creative.duration_ms == 0 or progress.played_ms < ad.creative.duration_ms * (index + 1) / 4) continue;
+ try self.event(worker, ([_][]const u8{ "FIRST_QUARTILE", "MIDPOINT", "THIRD_QUARTILE" })[index]);
+ sent.* = true;
+ }
+ }
+ }
+ if (progress.finished and !modal) break;
+ _ = try worker.idle();
+ c.SDL_Delay(4);
+ }
+ try self.event(worker, "COMPLETE");
+ }
+
+ fn event(self: *Playback, worker: *network.Worker, name: []const u8) !void {
+ if (self.provider == 1) try worker.report(.{ .action = .event, .event = name, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms });
+ }
+ fn loadTexture(self: *Playback, worker: *network.Worker, url: []const u8, max_width: c_int, max_height: c_int) !ImageTexture {
+ const bytes = try self.fetch(worker, url, 4 * 1024 * 1024);
+ defer self.allocator.free(bytes);
+ var image = std.mem.zeroes(c.GoArtworkImage);
+ if (c.go_artwork_decode_rgba(bytes.ptr, bytes.len, max_width, max_height, &image) != 0) return error.InvalidAdIcon;
+ defer c.go_artwork_image_destroy(&image);
+ const texture = c.SDL_CreateTexture(self.renderer, c.SDL_PIXELFORMAT_RGBA32, c.SDL_TEXTUREACCESS_STATIC, image.width, image.height) orelse return error.AdIconTextureUnavailable;
+ errdefer c.SDL_DestroyTexture(texture);
+ if (c.SDL_UpdateTexture(texture, null, image.pixels, image.stride) != 0 or
+ c.SDL_SetTextureBlendMode(texture, c.SDL_BLENDMODE_BLEND) != 0) return error.AdIconTextureUnavailable;
+ return .{ .handle = texture, .width = @intCast(image.width), .height = @intCast(image.height) };
+ }
+};
+
+fn resolveInline(storage: std.mem.Allocator, seed: Metadata, ad: vast.Ad) !Resolved {
+ const creative = ad.body.inline_ad;
+ const metadata = Metadata{
+ .impressions = try policy.merge([]const u8, storage, seed.impressions, ad.impressions),
+ .errors = try policy.merge([]const u8, storage, seed.errors, ad.errors),
+ .tracks = try policy.merge(vast.Track, storage, seed.tracks, creative.tracks),
+ .icons = try policy.merge(vast.Icon, storage, seed.icons, creative.icons),
+ };
+ if (metadata.icons.len > 8 or metadata.impressions.len > 128) return error.VastLimitExceeded;
+ return .{ .creative = creative, .metadata = metadata };
+}
+
+fn beacon(worker: *network.Worker, allocator: std.mem.Allocator, url: []const u8, played: u64, error_code: u16) !void {
+ const address = try policy.expand(allocator, url, played, error_code, 0);
+ defer allocator.free(address);
+ try worker.beacon(address);
+}
diff --git a/src/provider/geforce_now/ad_policy.zig b/src/provider/geforce_now/ad_policy.zig
new file mode 100644
index 0000000..52b5472
--- /dev/null
+++ b/src/provider/geforce_now/ad_policy.zig
@@ -0,0 +1,267 @@
+const std = @import("std");
+const vast = @import("vast.zig");
+
+pub fn selectAds(allocator: std.mem.Allocator, ads: []const vast.Ad, allow_multiple: bool, follow_wrappers: bool) ![]const vast.Ad {
+ var selected = std.ArrayList(vast.Ad).init(allocator);
+ errdefer selected.deinit();
+ const has_pod = allow_multiple and for (ads) |ad| {
+ if (ad.sequence != null) break true;
+ } else false;
+ for (ads) |ad| {
+ if (!follow_wrappers and ad.body == .wrapper) continue;
+ if ((has_pod and ad.sequence == null) or (!has_pod and ad.sequence != null)) continue;
+ try selected.append(ad);
+ if (!has_pod) break;
+ }
+ return selected.toOwnedSlice();
+}
+
+pub fn errorCode(err: anyerror) u16 {
+ return switch (err) {
+ error.InvalidXml, error.InvalidVastRoot, error.XmlDoctypeForbidden => 100,
+ error.VastWrapperLimit => 302,
+ error.VastEmptyResponse, error.VastNoAdsAfterWrapper => 303,
+ error.UnsupportedMedia, error.UnsupportedCodec, error.UnsupportedPixelFormat => 403,
+ error.UnsupportedInteractiveAd => 409,
+ error.AdDownloadFailed, error.AdDownloadRejected => 401,
+ else => 405,
+ };
+}
+
+pub fn merge(comptime T: type, allocator: std.mem.Allocator, left: []const T, right: []const T) ![]const T {
+ if (left.len + right.len > 256) return error.VastLimitExceeded;
+ const result = try allocator.alloc(T, left.len + right.len);
+ @memcpy(result[0..left.len], left);
+ @memcpy(result[left.len..], right);
+ return result;
+}
+pub fn selectMedia(files: []const vast.Media) ?vast.Media {
+ var result: ?vast.Media = null;
+ for (files) |file| {
+ if (result == null or @as(u32, file.width) * file.height < @as(u32, result.?.width) * result.?.height)
+ result = file;
+ }
+ return result;
+}
+pub fn due(track: vast.Track, played: u64, length: u64, finished: bool) bool {
+ if (std.mem.eql(u8, track.event, "complete")) return finished;
+ const threshold: u64 = if (std.mem.eql(u8, track.event, "start") or std.mem.eql(u8, track.event, "creativeView")) 0 else if (std.mem.eql(u8, track.event, "firstQuartile")) length / 4 else if (std.mem.eql(u8, track.event, "midpoint")) length / 2 else if (std.mem.eql(u8, track.event, "thirdQuartile")) length * 3 / 4 else if (std.mem.eql(u8, track.event, "progress")) track.offset_ms orelse length * (track.offset_percent orelse return false) / 100 else return false;
+ return played >= threshold;
+}
+pub fn iconVisible(icon: vast.Icon, played: u64) bool {
+ return played >= icon.offset_ms and (icon.duration_ms == null or played - icon.offset_ms < icon.duration_ms.?);
+}
+pub fn iconPosition(position: vast.Position, extent: c_int, size: u16) c_int {
+ return switch (position) {
+ .start => 0,
+ .end => @max(0, extent - size),
+ .pixel => |value| @min(value, @max(0, extent - size)),
+ };
+}
+
+pub fn fitImage(width: u16, height: u16, max_width: c_int, max_height: c_int) !struct { width: c_int, height: c_int } {
+ if (width == 0 or height == 0 or max_width <= 0 or max_height <= 0) return error.InvalidImageSize;
+ const w: u64 = @intCast(@min(width, max_width));
+ const h: u64 = @intCast(@min(height, max_height));
+ if (w * height <= h * width) return .{ .width = @intCast(w), .height = @intCast(@max(1, w * height / width)) };
+ return .{ .width = @intCast(@max(1, h * width / height)), .height = @intCast(h) };
+}
+pub fn expand(allocator: std.mem.Allocator, url: []const u8, played: u64, error_code: u16, verification: u8) ![]u8 {
+ var result = std.ArrayList(u8).init(allocator);
+ errdefer result.deinit();
+ var buffer: [64]u8 = undefined;
+ const macros = [_][]const u8{ "[CACHEBUSTING]", "[ERRORCODE]", "[CONTENTPLAYHEAD]", "[REASON]", "[TIMESTAMP]", "[ADPLAYHEAD]", "[MEDIAPLAYHEAD]" };
+ var cursor: usize = 0;
+ while (cursor < url.len) {
+ var replaced = false;
+ for (macros, 0..) |macro, index| {
+ if (!std.mem.startsWith(u8, url[cursor..], macro)) continue;
+ const value = switch (index) {
+ 0 => try std.fmt.bufPrint(&buffer, "{d:0>8}", .{std.crypto.random.intRangeAtMost(u32, 10000000, 99999999)}),
+ 1 => try std.fmt.bufPrint(&buffer, "{d}", .{error_code}),
+ 2, 6 => "00%3A00%3A00.000",
+ 3 => try std.fmt.bufPrint(&buffer, "{d}", .{verification}),
+ 4 => try timestamp(&buffer, std.time.milliTimestamp()),
+ 5 => try std.fmt.bufPrint(&buffer, "{d:0>2}%3A{d:0>2}%3A{d:0>2}.{d:0>3}", .{ played / 3600000, played / 60000 % 60, played / 1000 % 60, played % 1000 }),
+ else => return error.UnsupportedTrackingMacro,
+ };
+ try result.appendSlice(value);
+ cursor += macro.len;
+ replaced = true;
+ break;
+ }
+ if (!replaced) {
+ if (url[cursor] == '[') {
+ if (std.mem.indexOfScalarPos(u8, url, cursor + 1, ']')) |end| {
+ if (unavailableMacro(url[cursor + 1 .. end])) {
+ try result.appendSlice("-1");
+ if (result.items.len > 8192) return error.InvalidAdUrl;
+ cursor = end + 1;
+ continue;
+ }
+ }
+ }
+ try result.append(url[cursor]);
+ cursor += 1;
+ }
+ if (result.items.len > 8192) return error.InvalidAdUrl;
+ }
+ return result.toOwnedSlice();
+}
+
+fn unavailableMacro(name: []const u8) bool {
+ // VAST's unknown-value marker applies to defined macros, not vendor tokens.
+ const optional = [_][]const u8{
+ "BREAKPOSITION", "BLOCKEDADCATEGORIES", "ADCATEGORIES", "ADCOUNT", "TRANSACTIONID",
+ "PLACEMENTTYPE", "ADTYPE", "UNIVERSALADID", "BREAKMAXDURATION", "BREAKMAXADS",
+ "BREAKMINADLENGTH", "BREAKMAXADLENGTH", "IFA", "IFATYPE", "CLIENTUA",
+ "SERVERUA", "DEVICEUA", "SERVERSIDE", "DEVICEIP", "LATLONG",
+ "DOMAIN", "PAGEURL", "APPBUNDLE", "VASTVERSIONS", "APIFRAMEWORKS",
+ "EXTENSIONS", "VERIFICATIONVENDORS", "OMIDPARTNER", "MEDIAMIME", "PLAYERCAPABILITIES",
+ "CLICKTYPE", "PLAYERSTATE", "INVENTORYSTATE", "PLAYERSIZE", "ASSETURI",
+ "CONTENTID", "CONTENTURI", "PODSEQUENCE", "ADSERVINGID", "CLICKPOS",
+ "REGULATIONS", "GDPRCONSENT", "STOREID", "STOREURL", "PLAYBACKMETHODS",
+ "CONTENTCAT", "GPPSTRING", "GPPSECTIONID", "DSAREQUIRED", "DSAPARAMS",
+ "DSAPUBRENDER",
+ };
+ for (optional) |macro| if (std.mem.eql(u8, name, macro)) return true;
+ return false;
+}
+
+fn timestamp(buffer: []u8, milliseconds: i64) ![]const u8 {
+ if (milliseconds < 0) return error.InvalidTimestamp;
+ const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(@divTrunc(milliseconds, 1000)) };
+ const day = seconds.getEpochDay().calculateYearDay();
+ const month = day.calculateMonthDay();
+ const time = seconds.getDaySeconds();
+ return std.fmt.bufPrint(buffer, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}%3A{d:0>2}%3A{d:0>2}.{d:0>3}Z", .{ day.year, month.month.numeric(), month.day_index + 1, time.getHoursIntoDay(), time.getMinutesIntoHour(), time.getSecondsIntoMinute(), @as(u16, @intCast(@mod(milliseconds, 1000))) });
+}
+
+pub fn tagUrl(allocator: std.mem.Allocator, url: []const u8, app_id: []const u8) ![]u8 {
+ try vast.validateUrl(url);
+ for (app_id) |byte| if (!std.ascii.isDigit(byte)) return error.InvalidAppId;
+ var result = std.ArrayList(u8).init(allocator);
+ errdefer result.deinit();
+ const query = std.mem.indexOfScalar(u8, url, '?');
+ try result.appendSlice(url[0 .. query orelse url.len]);
+ try result.append('?');
+ var buffer: [512]u8 = undefined;
+ const supplied = try std.fmt.bufPrint(&buffer, "correlator={d}&scor={d}&description_url=https%253A%252F%252Fplay.geforcenow.com%252Fgames%253Fgame-id%253D{s}&url=https%3A%2F%2Fgithub.com%2FProducdevity%2FGreenOvercast&plcmt=1&vpmute=0&dth=6", .{ std.crypto.random.intRangeAtMost(u64, 1, 9007199254740991), std.time.milliTimestamp(), app_id });
+ var defaults = std.mem.splitScalar(u8, supplied, '&');
+ const existing = if (query) |index| url[index + 1 ..] else "";
+ var original_fields = std.mem.splitScalar(u8, existing, '&');
+ while (original_fields.next()) |field| {
+ if (field.len == 0) continue;
+ const key_end = std.mem.indexOfScalar(u8, field, '=') orelse field.len;
+ const empty = key_end == field.len or key_end + 1 == field.len;
+ var replace_empty = false;
+ var default_fields = std.mem.splitScalar(u8, supplied, '&');
+ while (default_fields.next()) |replacement| {
+ const end = std.mem.indexOfScalar(u8, replacement, '=').?;
+ if (empty and std.mem.eql(u8, field[0..key_end], replacement[0..end])) replace_empty = true;
+ }
+ if (!replace_empty) {
+ try result.appendSlice(field);
+ try result.append('&');
+ }
+ }
+ while (defaults.next()) |field| {
+ const key_end = std.mem.indexOfScalar(u8, field, '=').?;
+ var fields = std.mem.splitScalar(u8, existing, '&');
+ var present = false;
+ while (fields.next()) |candidate| {
+ const end = std.mem.indexOfScalar(u8, candidate, '=') orelse candidate.len;
+ if (end + 1 < candidate.len and std.mem.eql(u8, candidate[0..end], field[0..key_end])) {
+ present = true;
+ break;
+ }
+ }
+ if (!present) {
+ try result.appendSlice(field);
+ try result.append('&');
+ }
+ }
+ if (result.items[result.items.len - 1] == '&') result.items.len -= 1;
+ if (result.items.len > 8192) return error.InvalidAdUrl;
+ return result.toOwnedSlice();
+}
+
+test "empty request-context fields are populated without changing consent" {
+ const address = try tagUrl(std.testing.allocator, "https://example.invalid/?correlator=&scor&gdpr_consent=&npa=1", "12");
+ defer std.testing.allocator.free(address);
+ try std.testing.expect(std.mem.indexOf(u8, address, "gdpr_consent=&npa=1") != null);
+ try std.testing.expect(std.mem.indexOf(u8, address, "correlator=&") == null);
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, address, "correlator="));
+}
+
+test "pod selection excludes the buffet and honors wrapper restrictions" {
+ const creative = vast.Creative{ .duration_ms = 5000, .media = &.{}, .tracks = &.{}, .icons = &.{} };
+ const ads = [_]vast.Ad{
+ .{ .sequence = 1, .impressions = &.{}, .errors = &.{}, .body = .{ .inline_ad = creative } },
+ .{ .sequence = 2, .impressions = &.{}, .errors = &.{}, .body = .{ .inline_ad = creative } },
+ .{ .sequence = null, .impressions = &.{}, .errors = &.{}, .body = .{ .inline_ad = creative } },
+ };
+ const pod = try selectAds(std.testing.allocator, &ads, true, true);
+ defer std.testing.allocator.free(pod);
+ try std.testing.expectEqual(@as(usize, 2), pod.len);
+ const single = try selectAds(std.testing.allocator, &ads, false, true);
+ defer std.testing.allocator.free(single);
+ try std.testing.expectEqual(@as(usize, 1), single.len);
+ try std.testing.expect(single[0].sequence == null);
+ const sequenced_only = try selectAds(std.testing.allocator, ads[0..1], false, true);
+ defer std.testing.allocator.free(sequenced_only);
+ try std.testing.expectEqual(@as(usize, 0), sequenced_only.len);
+ const wrapper = [_]vast.Ad{.{ .sequence = null, .impressions = &.{}, .errors = &.{}, .body = .{ .wrapper = .{ .url = "https://example.invalid", .follow_wrappers = true, .allow_multiple = false, .fallback_on_no_ad = false } } }};
+ const ignored = try selectAds(std.testing.allocator, &wrapper, true, false);
+ defer std.testing.allocator.free(ignored);
+ try std.testing.expectEqual(@as(usize, 0), ignored.len);
+}
+
+test "progress thresholds and completion follow playback, not wall time" {
+ const quarter = vast.Track{ .event = "firstQuartile", .url = "https://example.invalid" };
+ try std.testing.expect(!due(quarter, 1249, 5000, false));
+ try std.testing.expect(due(quarter, 1250, 5000, false));
+ const complete = vast.Track{ .event = "complete", .url = "https://example.invalid" };
+ try std.testing.expect(!due(complete, 10000, 5000, false));
+ try std.testing.expect(due(complete, 5000, 5000, true));
+ const offset = vast.Track{ .event = "progress", .url = "https://example.invalid", .offset_percent = 75 };
+ try std.testing.expect(!due(offset, 3749, 5000, false));
+ try std.testing.expect(due(offset, 3750, 5000, false));
+}
+
+test "AdChoices images fit without cropping or enlarging" {
+ const cases = [_]struct { width: u16, height: u16, expected_width: c_int, expected_height: c_int }{
+ .{ .width = 1920, .height = 1080, .expected_width = 640, .expected_height = 360 },
+ .{ .width = 400, .height = 150, .expected_width = 400, .expected_height = 150 },
+ .{ .width = 600, .height = 1080, .expected_width = 244, .expected_height = 440 },
+ .{ .width = 1, .height = 1080, .expected_width = 1, .expected_height = 440 },
+ };
+ for (cases) |case| {
+ const size = try fitImage(case.width, case.height, 640, 440);
+ try std.testing.expectEqual(case.expected_width, size.width);
+ try std.testing.expectEqual(case.expected_height, size.height);
+ }
+ try std.testing.expectError(error.InvalidImageSize, fitImage(0, 1, 640, 440));
+ try std.testing.expectError(error.InvalidImageSize, fitImage(1, 1, 640, 0));
+}
+
+test "tracking macros distinguish ad time from the unstarted game" {
+ const encoded = try expand(std.testing.allocator, "https://example.invalid/?ad=[ADPLAYHEAD]&game=[MEDIAPLAYHEAD]&error=[ERRORCODE]&reason=[REASON]", 1250, 303, 2);
+ defer std.testing.allocator.free(encoded);
+ try std.testing.expectEqualStrings("https://example.invalid/?ad=00%3A00%3A01.250&game=00%3A00%3A00.000&error=303&reason=2", encoded);
+ var buffer: [64]u8 = undefined;
+ try std.testing.expectEqualStrings("1970-01-01T00%3A00%3A00.000Z", try timestamp(&buffer, 0));
+ const unavailable = try expand(std.testing.allocator, "https://[2001:4860::8888]/?asset=[ASSETURI]&id=[ADSERVINGID]&ua=[DEVICEUA]&state=[PLAYERSTATE]&break=[BREAKPOSITION]&custom=[VENDOR_TOKEN]", 0, 0, 0);
+ defer std.testing.allocator.free(unavailable);
+ try std.testing.expectEqualStrings("https://[2001:4860::8888]/?asset=-1&id=-1&ua=-1&state=-1&break=-1&custom=[VENDOR_TOKEN]", unavailable);
+}
+
+test "tag preparation preserves supplied consent, targeting and request identity" {
+ const address = try tagUrl(std.testing.allocator, "https://example.invalid/tag?npa=1<d=1&correlator=123&url=supplied", "12345");
+ defer std.testing.allocator.free(address);
+ try std.testing.expect(std.mem.indexOf(u8, address, "npa=1<d=1&correlator=123&url=supplied") != null);
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, address, "correlator="));
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, address, "&url="));
+ try std.testing.expect(std.mem.indexOf(u8, address, "sdkv") == null);
+ try std.testing.expect(std.mem.indexOf(u8, address, "game-id%253D12345") != null);
+}
diff --git a/src/provider/geforce_now/ad_protocol.zig b/src/provider/geforce_now/ad_protocol.zig
new file mode 100644
index 0000000..d7595f7
--- /dev/null
+++ b/src/provider/geforce_now/ad_protocol.zig
@@ -0,0 +1,181 @@
+const std = @import("std");
+const endpoint = @import("endpoint.zig");
+
+pub const Opportunity = struct {
+ id: []const u8,
+ url: []const u8,
+ state: u8,
+ duration_ms: u64,
+};
+
+pub const Admission = struct {
+ arena: std.heap.ArenaAllocator,
+ required: bool,
+ provider: u8,
+ control_base: ?[]const u8,
+ opportunities: []const Opportunity,
+
+ pub fn deinit(self: *Admission) void {
+ self.arena.deinit();
+ }
+};
+
+pub const Action = enum(u8) { start = 1, pause = 2, resume_playback = 3, finish = 4, cancel = 5, event = 6 };
+pub const Update = struct {
+ action: Action,
+ watched_ms: u64 = 0,
+ paused_ms: u64 = 0,
+ duration_ms: u64 = 0,
+ reason: ?[]const u8 = null,
+ event: ?[]const u8 = null,
+};
+
+pub fn parse(allocator: std.mem.Allocator, session: std.json.ObjectMap) !Admission {
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ errdefer arena.deinit();
+ const storage = arena.allocator();
+ const required = if (session.get("sessionAdsRequired")) |value| switch (value) {
+ .bool => |flag| flag,
+ .null => false,
+ else => return error.InvalidAdsRequired,
+ } else false;
+ if (!required) return .{ .arena = arena, .required = false, .provider = 0, .control_base = null, .opportunities = &.{} };
+ const provider = if (session.get("adProvider")) |value| if (value == .null) 0 else try unsigned(u8, value) else 0;
+ const control_base = try controlBase(storage, session.get("sessionControlInfo"));
+ var ads = std.ArrayList(Opportunity).init(storage);
+ if (session.get("sessionAds")) |values| {
+ if (values != .array and values != .null) return error.InvalidAdList;
+ if (values == .array and values.array.items.len > 16) return error.InvalidAdList;
+ if (values == .array) for (values.array.items) |value| {
+ if (value != .object) return error.InvalidAd;
+ const fields = value.object;
+ const state = if (fields.get("adState")) |number| try unsigned(u8, number) else 0;
+ if (state > 6) return error.InvalidAdState;
+ if (state == 5 or state == 6) continue;
+ const id = fields.get("adId") orelse return error.MissingAdId;
+ const url = fields.get("adUrl") orelse return error.MissingAdUrl;
+ if (id != .string or id.string.len == 0 or id.string.len > 256 or
+ url != .string or url.string.len == 0 or url.string.len > 8192)
+ return error.InvalidAd;
+ const seconds = if (fields.get("adLengthInSeconds")) |number| try unsigned(u16, number) else 0;
+ if (seconds > 120) return error.InvalidAdDuration;
+ try ads.append(.{ .id = try storage.dupe(u8, id.string), .url = try storage.dupe(u8, url.string), .state = state, .duration_ms = @as(u64, seconds) * 1000 });
+ };
+ }
+ return .{ .arena = arena, .required = required, .provider = provider, .control_base = control_base, .opportunities = try ads.toOwnedSlice() };
+}
+
+fn controlBase(allocator: std.mem.Allocator, control: ?std.json.Value) !?[]const u8 {
+ const value = control orelse return null;
+ if (value != .object) return null;
+ const host = value.object.get("ip") orelse return null;
+ if (host != .string or std.mem.indexOfAny(u8, host.string, "/@%?#\\") != null) return null;
+ if (value.object.get("port")) |port| {
+ if ((unsigned(u16, port) catch return null) != 443) return null;
+ }
+ const address = try std.fmt.allocPrint(allocator, "https://{s}/", .{host.string});
+ return endpoint.normalizeCloudMatchBase(allocator, address) catch |err| switch (err) {
+ error.OutOfMemory => return err,
+ else => null,
+ };
+}
+
+test "nullable ad metadata is not an invalid session while provisioning" {
+ const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAds\":null,\"sessionAdsRequired\":null,\"adProvider\":null,\"sessionControlInfo\":null}", .{});
+ defer json.deinit();
+ var admission = try parse(std.testing.allocator, json.value.object);
+ defer admission.deinit();
+ try std.testing.expect(!admission.required);
+ try std.testing.expectEqual(@as(usize, 0), admission.opportunities.len);
+ try std.testing.expectEqual(@as(u8, 0), admission.provider);
+}
+
+fn unsigned(comptime T: type, value: std.json.Value) !T {
+ if (value != .integer or value.integer < 0 or value.integer > std.math.maxInt(T)) return error.InvalidAdNumber;
+ return @intCast(value.integer);
+}
+
+pub fn buildUpdate(allocator: std.mem.Allocator, id: []const u8, update: Update, timestamp: i64) ![]u8 {
+ if (id.len == 0 or id.len > 256 or timestamp < 0) return error.InvalidAdUpdate;
+ if (update.action == .cancel and update.reason == null) return error.MissingCancelReason;
+ if (update.action == .event and update.event == null) return error.MissingEvent;
+ return std.json.stringifyAlloc(allocator, .{ .action = 6, .adUpdates = &.{.{
+ .adId = id,
+ .adAction = @intFromEnum(update.action),
+ .clientTimestamp = timestamp,
+ .watchedTimeInMs = update.watched_ms,
+ .pausedTimeInMs = update.paused_ms,
+ .adDurationInMs = update.duration_ms,
+ .cancelReason = update.reason,
+ .eventName = update.event,
+ }} }, .{ .emit_null_optional_fields = false });
+}
+
+test "no-fill cancellation uses the original pod ID and no fabricated playback" {
+ const body = try buildUpdate(std.testing.allocator, "opaque-pod", .{ .action = .cancel, .reason = "VAST_EMPTY_RESPONSE" }, 123);
+ defer std.testing.allocator.free(body);
+ const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, body, .{});
+ defer parsed.deinit();
+ const update = parsed.value.object.get("adUpdates").?.array.items[0].object;
+ try std.testing.expectEqualStrings("opaque-pod", update.get("adId").?.string);
+ try std.testing.expectEqual(@as(i64, 5), update.get("adAction").?.integer);
+ try std.testing.expectEqual(@as(i64, 123), update.get("clientTimestamp").?.integer);
+ try std.testing.expectEqual(@as(i64, 0), update.get("watchedTimeInMs").?.integer);
+ try std.testing.expect(update.get("eventName") == null);
+}
+
+test "ad metadata is owned and finished opportunities are excluded" {
+ const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":true,\"adProvider\":1,\"sessionControlInfo\":{\"ip\":\"np.example.nvidiagrid.net\",\"port\":443},\"sessionAds\":[{\"adId\":\"pod\",\"adState\":1,\"adLengthInSeconds\":30,\"adUrl\":\"https://example.invalid/tag\"},{\"adState\":5}]}", .{});
+ var admission = try parse(std.testing.allocator, json.value.object);
+ json.deinit();
+ defer admission.deinit();
+ try std.testing.expect(admission.required);
+ try std.testing.expectEqual(@as(usize, 1), admission.opportunities.len);
+ try std.testing.expectEqualStrings("pod", admission.opportunities[0].id);
+ try std.testing.expectEqualStrings("https://np.example.nvidiagrid.net/", admission.control_base.?);
+}
+
+test "untrusted ad control hosts cannot receive account credentials" {
+ const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":true,\"sessionControlInfo\":{\"ip\":\"evil.invalid\",\"port\":443}}", .{});
+ defer json.deinit();
+ var admission = try parse(std.testing.allocator, json.value.object);
+ defer admission.deinit();
+ try std.testing.expect(admission.control_base == null);
+}
+
+test "unsupported control addresses leave ad reporting on the trusted session endpoint" {
+ for ([_][]const u8{ "null", "123", "{\"ip\":123}", "{\"ip\":null}", "{\"ip\":{\"address\":\"192.0.2.1\"}}", "{\"ip\":\"192.0.2.1\"}", "{\"ip\":\"np.example.nvidiagrid.net\",\"port\":80}", "{\"ip\":\"np.example.nvidiagrid.net\",\"port\":\"invalid\"}" }) |control| {
+ const bytes = try std.fmt.allocPrint(std.testing.allocator, "{{\"sessionAdsRequired\":true,\"sessionControlInfo\":{s}}}", .{control});
+ defer std.testing.allocator.free(bytes);
+ const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, bytes, .{});
+ defer json.deinit();
+ var admission = try parse(std.testing.allocator, json.value.object);
+ defer admission.deinit();
+ try std.testing.expect(admission.required and admission.control_base == null);
+ }
+}
+
+test "irrelevant ad metadata does not reject an ad-free session" {
+ const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":false,\"adProvider\":\"invalid\",\"sessionAds\":42}", .{});
+ defer json.deinit();
+ var admission = try parse(std.testing.allocator, json.value.object);
+ defer admission.deinit();
+ try std.testing.expect(!admission.required and admission.opportunities.len == 0);
+}
+
+test "malformed required ads cannot bypass admission" {
+ const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":true,\"sessionAds\":42}", .{});
+ defer json.deinit();
+ try std.testing.expectError(error.InvalidAdList, parse(std.testing.allocator, json.value.object));
+}
+
+test "ad admission preserves allocation failures" {
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
+ fn run(allocator: std.mem.Allocator) !void {
+ const json = try std.json.parseFromSlice(std.json.Value, allocator, "{\"sessionAdsRequired\":true,\"sessionControlInfo\":{\"ip\":\"np.example.nvidiagrid.net\"},\"sessionAds\":[{\"adId\":\"one\",\"adUrl\":\"https://example.invalid/ad\"}]}", .{});
+ defer json.deinit();
+ var admission = try parse(allocator, json.value.object);
+ defer admission.deinit();
+ }
+ }.run, .{});
+}
diff --git a/src/provider/geforce_now/auth_client.zig b/src/provider/geforce_now/auth_client.zig
new file mode 100644
index 0000000..6e90c0e
--- /dev/null
+++ b/src/provider/geforce_now/auth_client.zig
@@ -0,0 +1,667 @@
+const std = @import("std");
+const protocol = @import("auth_protocol.zig");
+const provider_protocol = @import("provider_protocol.zig");
+const uuid = @import("uuid");
+
+const c = @cImport({
+ @cInclude("SDL2/SDL.h");
+ @cInclude("handheld_ui.h");
+ @cInclude("http_client.h");
+ @cInclude("token_store_adapter.h");
+});
+
+const max_config_length = 256;
+const max_credential_length = 64 * 1024;
+
+// Public identifiers for NVIDIA's device-code flow and game catalog.
+const default_oauth_client_id = "q61ddeJrVt7O90Nl-P-N7I36yctih4Ml6FyXLrb6j-U";
+const default_protocol_client_id = "ec7e38d4-03af-4b58-b131-cfb0495903ab";
+
+pub const RefreshResult = enum {
+ ok,
+ reauth_required,
+ failed,
+};
+
+const TokenGrantResult = union(enum) {
+ ok: protocol.Tokens,
+ rejected,
+ failed,
+};
+
+const ClientTokenResult = enum {
+ ok,
+ rejected,
+ failed,
+};
+
+pub const Client = struct {
+ allocator: std.mem.Allocator,
+ ui: *c.GoHandheldUi,
+ oauth_client_id: []u8,
+ protocol_client_id: []u8,
+ credential_path: [:0]u8,
+ key_path: [:0]u8,
+ device_id_path: [:0]u8,
+ device_id: [37]u8,
+ provider: ?provider_protocol.Provider = null,
+ tokens: ?protocol.Tokens = null,
+
+ pub fn create(allocator: std.mem.Allocator, ui_pointer: *anyopaque) !*Client {
+ const oauth_client_id = try loadConfig(
+ allocator,
+ std.posix.getenv("GREENOVERCAST_GFN_CLIENT_ID"),
+ std.posix.getenv("GREENOVERCAST_GFN_CLIENT_ID_FILE"),
+ default_oauth_client_id,
+ );
+ errdefer allocator.free(oauth_client_id);
+ if (!validOpaqueId(oauth_client_id)) return error.InvalidClientId;
+
+ const protocol_client_id = try loadConfig(
+ allocator,
+ std.posix.getenv("GREENOVERCAST_GFN_PROTOCOL_CLIENT_ID"),
+ std.posix.getenv("GREENOVERCAST_GFN_PROTOCOL_CLIENT_ID_FILE"),
+ default_protocol_client_id,
+ );
+ errdefer allocator.free(protocol_client_id);
+ if (!validOpaqueId(protocol_client_id)) return error.InvalidClientId;
+
+ const credential_path = try requiredEnvironmentCopy(allocator, "GREENOVERCAST_GFN_TOKEN_FILE");
+ errdefer allocator.free(credential_path);
+ const key_path = try requiredEnvironmentCopy(allocator, "GREENOVERCAST_GFN_TOKEN_KEY_FILE");
+ errdefer allocator.free(key_path);
+ const device_id_path = try requiredEnvironmentCopy(allocator, "GREENOVERCAST_GFN_DEVICE_ID_FILE");
+ errdefer allocator.free(device_id_path);
+
+ const client = try allocator.create(Client);
+ errdefer allocator.destroy(client);
+ client.* = .{
+ .allocator = allocator,
+ .ui = @ptrCast(ui_pointer),
+ .oauth_client_id = oauth_client_id,
+ .protocol_client_id = protocol_client_id,
+ .credential_path = credential_path,
+ .key_path = key_path,
+ .device_id_path = device_id_path,
+ .device_id = [_]u8{0} ** 37,
+ };
+ try client.loadOrCreateDeviceId();
+ return client;
+ }
+
+ pub fn destroy(self: *Client) void {
+ if (self.tokens) |*tokens| tokens.deinit();
+ if (self.provider) |*provider| provider.deinit();
+ self.allocator.free(self.oauth_client_id);
+ self.allocator.free(self.protocol_client_id);
+ self.allocator.free(self.credential_path);
+ self.allocator.free(self.key_path);
+ self.allocator.free(self.device_id_path);
+ const allocator = self.allocator;
+ allocator.destroy(self);
+ }
+
+ pub fn loadCredentials(self: *Client) !bool {
+ var encrypted: [max_credential_length]u8 = [_]u8{0} ** max_credential_length;
+ defer std.crypto.secureZero(u8, &encrypted);
+ const result = c.go_token_store_load(
+ @ptrCast(self.credential_path.ptr),
+ @ptrCast(self.key_path.ptr),
+ @ptrCast(&encrypted),
+ encrypted.len,
+ );
+ if (result < 0) return error.CredentialLoadFailed;
+ if (result == 0) return false;
+ const data = std.mem.sliceTo(&encrypted, 0);
+ var tokens = protocol.parseStoredTokens(self.allocator, data) catch |err| switch (err) {
+ error.MissingField => return false,
+ else => return err,
+ };
+ errdefer tokens.deinit();
+ if (self.tokens) |*previous| previous.deinit();
+ self.tokens = tokens;
+ return true;
+ }
+
+ pub fn signIn(self: *Client) !bool {
+ try self.discoverProvider();
+ const provider = &self.provider.?;
+ var form_buffer: [4096]u8 = undefined;
+ const body = try protocol.buildDeviceAuthorizationForm(.{
+ .client_id = self.oauth_client_id,
+ .device_id = std.mem.sliceTo(&self.device_id, 0),
+ .display_name = "GreenOvercast",
+ .idp_id = provider.idp_id,
+ }, &form_buffer);
+
+ var dynamic_headers: [8][256]u8 = undefined;
+ var headers = [_][*c]const u8{
+ "Accept: application/json, text/plain, */*",
+ "Content-Type: application/x-www-form-urlencoded",
+ "Origin: https://play.geforcenow.com",
+ "Referer: https://play.geforcenow.com/",
+ try header(&dynamic_headers[0], "x-device-id", std.mem.sliceTo(&self.device_id, 0)),
+ try header(&dynamic_headers[1], "nv-client-id", self.oauth_client_id),
+ "nv-client-streamer: WEBRTC",
+ "nv-client-type: BROWSER",
+ "nv-client-platform-name: browser",
+ "nv-browser-type: CHROME",
+ "nv-device-os: STEAMOS",
+ "nv-device-type: CONSOLE",
+ "nv-device-model: STEAMDECK",
+ "nv-device-make: VALVE",
+ "User-Agent: Mozilla/5.0 (X11; Linux x86_64; Steam Deck) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36",
+ };
+
+ c.go_handheld_ui_draw_loading(
+ self.ui,
+ "GEFORCE NOW SIGN IN",
+ "REQUESTING A DEVICE CODE",
+ c.GO_HANDHELD_UI_ACTION_BACK,
+ );
+ var response = c.go_http_request(
+ "POST",
+ protocol.device_authorization_endpoint,
+ body.ptr,
+ @ptrCast(&headers),
+ headers.len,
+ );
+ defer c.go_http_response_destroy(response);
+ if (!successful(response)) return error.DeviceAuthorizationFailed;
+ var challenge = try protocol.parseDeviceChallenge(self.allocator, responseData(response).?);
+ defer challenge.deinit();
+ c.go_http_response_destroy(response);
+ response = null;
+
+ const address_value = challenge.verification_uri;
+ var address_buffer: [256]u8 = undefined;
+ const address = try std.fmt.bufPrintZ(&address_buffer, "{s}", .{address_value});
+ var code_buffer: [96]u8 = undefined;
+ const user_code = try std.fmt.bufPrintZ(&code_buffer, "{s}", .{challenge.user_code});
+ var interval = @max(challenge.interval, 1);
+ const started = c.SDL_GetTicks();
+ var last_poll = started;
+ while (true) {
+ const now = c.SDL_GetTicks();
+ const remaining = challenge.secondsRemaining(started, now);
+ if (remaining == 0) return error.CodeExpired;
+ c.go_handheld_ui_draw_device_code_for(
+ self.ui,
+ "GEFORCE NOW SIGN IN",
+ address.ptr,
+ user_code.ptr,
+ "WAITING FOR APPROVAL",
+ remaining,
+ );
+ if (c.go_handheld_ui_sign_in_action(self.ui) < 0) return false;
+ if ((now -% last_poll) / 1000 < interval) {
+ c.SDL_Delay(16);
+ continue;
+ }
+
+ const poll_body = try protocol.buildDeviceTokenForm(
+ self.oauth_client_id,
+ challenge.device_code,
+ &form_buffer,
+ );
+ response = c.go_http_request(
+ "POST",
+ protocol.token_endpoint,
+ poll_body.ptr,
+ @ptrCast(&headers),
+ headers.len,
+ );
+ last_poll = c.SDL_GetTicks();
+ if (successful(response)) {
+ var tokens = try protocol.parseGrantTokens(
+ self.allocator,
+ responseData(response).?,
+ std.time.timestamp(),
+ );
+ c.go_http_response_destroy(response);
+ response = null;
+ _ = self.fetchClientToken(&tokens, tokens.access_token);
+ if (self.tokens) |*previous| previous.deinit();
+ self.tokens = tokens;
+ self.saveCredentials() catch |err| {
+ self.tokens = null;
+ tokens.deinit();
+ return err;
+ };
+ c.go_handheld_ui_draw_loading(
+ self.ui,
+ "SIGNED IN",
+ "OPENING YOUR GEFORCE NOW LIBRARY",
+ c.GO_HANDHELD_UI_ACTION_NONE,
+ );
+ c.SDL_Delay(700);
+ return true;
+ }
+
+ const poll_error = if (responseData(response)) |data|
+ protocol.classifyPollError(self.allocator, data) catch .other
+ else
+ .other;
+ const status = if (response) |value| value.*.status else 0;
+ var error_code_buffer: [64]u8 = undefined;
+ const error_code = if (responseData(response)) |data|
+ protocol.writePollErrorCode(self.allocator, data, &error_code_buffer)
+ else
+ null;
+ c.go_http_response_destroy(response);
+ response = null;
+ switch (poll_error) {
+ .authorization_pending => {},
+ .slow_down => interval +|= 5,
+ else => {
+ std.debug.print(
+ "GeForce NOW token exchange failed: HTTP {d} ({s})\n",
+ .{ status, error_code orelse @tagName(poll_error) },
+ );
+ return switch (poll_error) {
+ .expired_token => error.CodeExpired,
+ .access_denied => error.AccessDenied,
+ else => error.TokenRequestFailed,
+ };
+ },
+ }
+ }
+ }
+
+ pub fn refresh(self: *Client) RefreshResult {
+ const previous = if (self.tokens) |*tokens| tokens else return .reauth_required;
+ if (self.provider == null) self.discoverProvider() catch return .failed;
+ if (!previous.needsRefresh(std.time.timestamp())) return .ok;
+
+ var form_buffer: [max_credential_length]u8 = undefined;
+ if (previous.client_token == null) {
+ const token_value = previous.id_token orelse previous.access_token;
+ switch (self.fetchClientToken(previous, token_value)) {
+ .ok => {},
+ .rejected => if (previous.refresh_token == null) return .reauth_required,
+ .failed => if (previous.refresh_token == null) return .failed,
+ }
+ }
+
+ if (previous.client_token) |client_token| {
+ const subject = protocol.jwtSubject(self.allocator, previous.bearer()) catch null;
+ if (subject) |value| {
+ defer self.allocator.free(value);
+ const body = protocol.buildClientTokenForm(
+ self.oauth_client_id,
+ client_token,
+ value,
+ &form_buffer,
+ ) catch return .failed;
+ switch (self.performTokenGrant(body, previous)) {
+ .ok => |refreshed| return self.finishRefresh(refreshed),
+ .rejected, .failed => {},
+ }
+ }
+ }
+
+ const refresh_token = previous.refresh_token orelse return .reauth_required;
+ const body = protocol.buildRefreshTokenForm(
+ self.oauth_client_id,
+ refresh_token,
+ &form_buffer,
+ ) catch return .failed;
+ return switch (self.performTokenGrant(body, previous)) {
+ .ok => |refreshed| self.finishRefresh(refreshed),
+ .rejected => .reauth_required,
+ .failed => .failed,
+ };
+ }
+
+ pub fn signOut(self: *Client) !void {
+ if (c.go_token_store_delete(
+ @ptrCast(self.credential_path.ptr),
+ @ptrCast(self.key_path.ptr),
+ ) != 0) return error.CredentialDeleteFailed;
+ if (self.tokens) |*tokens| tokens.deinit();
+ self.tokens = null;
+ }
+
+ pub fn bearer(self: *const Client) ?[]const u8 {
+ return if (self.tokens) |*tokens| tokens.bearer() else null;
+ }
+
+ pub fn userId(self: *const Client, allocator: std.mem.Allocator) ![]u8 {
+ return protocol.jwtSubject(allocator, self.bearer() orelse return error.MissingCredentials);
+ }
+
+ pub fn fetchUserAge(self: *const Client) !u8 {
+ const tokens = if (self.tokens) |*value| value else return error.MissingCredentials;
+ const subject = try self.userId(self.allocator);
+ defer self.allocator.free(subject);
+ const authorization = try std.fmt.allocPrintZ(self.allocator, "Authorization: Bearer {s}", .{tokens.access_token});
+ defer {
+ std.crypto.secureZero(u8, authorization);
+ self.allocator.free(authorization);
+ }
+ var headers = [_][*c]const u8{ authorization.ptr, "Accept: application/json" };
+ const response = c.go_http_request_bounded_cancelable(
+ "GET",
+ protocol.user_info_endpoint,
+ null,
+ @ptrCast(&headers),
+ headers.len,
+ max_credential_length,
+ cancelUserInfoRequest,
+ self.ui,
+ ) orelse {
+ if (c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled;
+ return error.UserInfoRequestFailed;
+ };
+ defer {
+ if (responseData(response)) |data| std.crypto.secureZero(u8, @constCast(data));
+ c.go_http_response_destroy(response);
+ }
+ if (response.*.status != 200) {
+ std.debug.print("GeForce NOW account lookup failed: HTTP {d}\n", .{response.*.status});
+ return error.UserInfoRequestFailed;
+ }
+ return protocol.parseUserAge(self.allocator, responseData(response) orelse return error.MissingUserAge, subject);
+ }
+
+ pub fn protocolClientId(self: *const Client) []const u8 {
+ return self.protocol_client_id;
+ }
+
+ pub fn streamingUrl(self: *const Client) ?[]const u8 {
+ return if (self.provider) |*provider| provider.streaming_url else null;
+ }
+
+ pub fn stableDeviceId(self: *const Client) []const u8 {
+ return std.mem.sliceTo(&self.device_id, 0);
+ }
+
+ fn discoverProvider(self: *Client) !void {
+ var headers = [_][*c]const u8{
+ "Accept: application/json",
+ "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36",
+ };
+ const response = c.go_http_request(
+ "GET",
+ provider_protocol.service_urls_endpoint,
+ null,
+ @ptrCast(&headers),
+ headers.len,
+ );
+ defer c.go_http_response_destroy(response);
+ if (!successful(response)) return error.ProviderDiscoveryFailed;
+ var provider = try provider_protocol.parsePreferredProvider(
+ self.allocator,
+ responseData(response).?,
+ );
+ errdefer provider.deinit();
+ if (self.provider) |*previous| previous.deinit();
+ self.provider = provider;
+ }
+
+ fn saveCredentials(self: *Client) !void {
+ const tokens = if (self.tokens) |*value| value else return error.MissingCredentials;
+ const serialized = try std.json.stringifyAlloc(self.allocator, .{
+ .access_token = tokens.access_token,
+ .refresh_token = tokens.refresh_token,
+ .id_token = tokens.id_token,
+ .client_token = tokens.client_token,
+ .expires_at = tokens.expires_at,
+ }, .{ .emit_null_optional_fields = true });
+ defer {
+ std.crypto.secureZero(u8, serialized);
+ self.allocator.free(serialized);
+ }
+ const terminated = try self.allocator.allocSentinel(u8, serialized.len, 0);
+ defer {
+ std.crypto.secureZero(u8, terminated);
+ self.allocator.free(terminated);
+ }
+ @memcpy(terminated[0..serialized.len], serialized);
+ if (c.go_token_store_save(
+ @ptrCast(self.credential_path.ptr),
+ @ptrCast(self.key_path.ptr),
+ terminated.ptr,
+ ) != 0) return error.CredentialSaveFailed;
+ }
+
+ fn fetchClientToken(
+ self: *Client,
+ tokens: *protocol.Tokens,
+ token_value: []const u8,
+ ) ClientTokenResult {
+ if (tokens.client_token != null) return .ok;
+ const authorization = std.fmt.allocPrintZ(
+ self.allocator,
+ "Authorization: Bearer {s}",
+ .{token_value},
+ ) catch return .failed;
+ defer {
+ std.crypto.secureZero(u8, authorization);
+ self.allocator.free(authorization);
+ }
+ var headers = [_][*c]const u8{
+ authorization.ptr,
+ "Accept: application/json, text/plain, */*",
+ "Origin: https://play.geforcenow.com",
+ "Referer: https://play.geforcenow.com/",
+ };
+ const response = c.go_http_request_bounded(
+ "GET",
+ protocol.client_token_endpoint,
+ null,
+ @ptrCast(&headers),
+ headers.len,
+ 1024 * 1024,
+ );
+ defer c.go_http_response_destroy(response);
+ if (!successful(response)) {
+ if (response != null and (response.*.status == 401 or response.*.status == 403))
+ return .rejected;
+ return .failed;
+ }
+ tokens.client_token = protocol.parseClientToken(
+ self.allocator,
+ responseData(response).?,
+ ) catch return .failed;
+ return .ok;
+ }
+
+ fn performTokenGrant(
+ self: *Client,
+ body: [:0]const u8,
+ previous: *const protocol.Tokens,
+ ) TokenGrantResult {
+ var headers = [_][*c]const u8{
+ "Accept: application/json, text/plain, */*",
+ "Content-Type: application/x-www-form-urlencoded",
+ "Origin: https://play.geforcenow.com",
+ "Referer: https://play.geforcenow.com/",
+ };
+ const response = c.go_http_request(
+ "POST",
+ protocol.token_endpoint,
+ body.ptr,
+ @ptrCast(&headers),
+ headers.len,
+ );
+ defer c.go_http_response_destroy(response);
+ if (!successful(response)) {
+ const status = if (response) |value| value.*.status else 0;
+ var error_code_buffer: [64]u8 = undefined;
+ const error_code = if (responseData(response)) |data|
+ protocol.writePollErrorCode(self.allocator, data, &error_code_buffer)
+ else
+ null;
+ std.debug.print(
+ "GeForce NOW token grant failed: HTTP {d} ({s})\n",
+ .{ status, error_code orelse "unknown" },
+ );
+ const failure = if (responseData(response)) |data|
+ protocol.classifyPollError(self.allocator, data) catch .other
+ else
+ .other;
+ return if (failure == .invalid_grant or failure == .access_denied)
+ .rejected
+ else
+ .failed;
+ }
+ const refreshed = protocol.mergeRefreshedTokens(
+ self.allocator,
+ previous,
+ responseData(response).?,
+ std.time.timestamp(),
+ ) catch return .failed;
+ return .{ .ok = refreshed };
+ }
+
+ fn finishRefresh(self: *Client, refreshed: protocol.Tokens) RefreshResult {
+ if (self.tokens) |*previous| previous.deinit();
+ self.tokens = refreshed;
+ self.saveCredentials() catch return .failed;
+ return .ok;
+ }
+
+ fn loadOrCreateDeviceId(self: *Client) !void {
+ const file = std.fs.cwd().openFile(self.device_id_path, .{}) catch |err| switch (err) {
+ error.FileNotFound => return self.createDeviceId(),
+ else => return err,
+ };
+ defer file.close();
+ var contents: [64]u8 = undefined;
+ const length = try file.readAll(&contents);
+ const value = std.mem.trim(u8, contents[0..length], " \t\r\n");
+ if (!uuid.valid(value)) return error.InvalidDeviceId;
+ @memcpy(self.device_id[0..value.len], value);
+ }
+
+ fn createDeviceId(self: *Client) !void {
+ uuid.generate(&self.device_id);
+ const value = std.mem.sliceTo(&self.device_id, 0);
+ var temporary_path_buffer: [std.fs.max_path_bytes]u8 = undefined;
+ const temporary_path = try std.fmt.bufPrint(
+ &temporary_path_buffer,
+ "{s}.tmp",
+ .{self.device_id_path},
+ );
+ const cwd = std.fs.cwd();
+ errdefer cwd.deleteFile(temporary_path) catch {};
+ {
+ var file = try cwd.createFile(temporary_path, .{
+ .truncate = true,
+ .mode = 0o600,
+ });
+ defer file.close();
+ try file.writeAll(value);
+ try file.writeAll("\n");
+ try file.sync();
+ }
+ try cwd.rename(temporary_path, self.device_id_path);
+ }
+};
+
+fn cancelUserInfoRequest(context: ?*anyopaque) callconv(.c) c_int {
+ const ui: *c.GoHandheldUi = @ptrCast(@alignCast(context orelse return 1));
+ return c.go_handheld_ui_cancel_requested(ui);
+}
+
+fn loadConfig(
+ allocator: std.mem.Allocator,
+ configured_value: ?[]const u8,
+ configured_path: ?[]const u8,
+ default_value: []const u8,
+) ![]u8 {
+ if (configured_value) |value| {
+ const trimmed = std.mem.trim(u8, value, " \t\r\n");
+ if (trimmed.len == 0 or trimmed.len > max_config_length) return error.InvalidConfig;
+ return allocator.dupe(u8, trimmed);
+ }
+ const path = configured_path orelse return allocator.dupe(u8, default_value);
+ const file = try std.fs.cwd().openFile(path, .{});
+ defer file.close();
+ const data = try file.readToEndAlloc(allocator, max_config_length);
+ defer allocator.free(data);
+ const trimmed = std.mem.trim(u8, data, " \t\r\n");
+ if (trimmed.len == 0) return error.InvalidConfig;
+ return allocator.dupe(u8, trimmed);
+}
+
+fn requiredEnvironmentCopy(allocator: std.mem.Allocator, name: []const u8) ![:0]u8 {
+ const value = std.posix.getenv(name) orelse return error.MissingConfig;
+ if (value.len == 0 or std.mem.indexOfScalar(u8, value, 0) != null) return error.InvalidConfig;
+ return allocator.dupeZ(u8, value);
+}
+
+fn validOpaqueId(value: []const u8) bool {
+ if (value.len < 20 or value.len > 128) return false;
+ for (value) |byte| {
+ if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_') return false;
+ }
+ return true;
+}
+
+fn successful(response: [*c]c.GoHttpResponse) bool {
+ return c.go_http_response_succeeded(response) != 0 and responseData(response) != null;
+}
+
+fn responseData(response: [*c]c.GoHttpResponse) ?[]const u8 {
+ if (response == null or response.*.data == null) return null;
+ return response.*.data[0..response.*.len];
+}
+
+fn header(output: []u8, name: []const u8, value: []const u8) ![*c]const u8 {
+ const result = try std.fmt.bufPrintZ(output, "{s}: {s}", .{ name, value });
+ return result.ptr;
+}
+
+test "validates opaque service identifiers" {
+ try std.testing.expect(validOpaqueId(default_oauth_client_id));
+ try std.testing.expect(validOpaqueId(default_protocol_client_id));
+ try std.testing.expect(validOpaqueId("sample-client-id_0123456789"));
+ try std.testing.expect(validOpaqueId("12345678-1234-4abc-8def-123456789abc"));
+ try std.testing.expect(!validOpaqueId("short"));
+ try std.testing.expect(!validOpaqueId("invalid value with spaces"));
+}
+
+test "service identifiers have defaults and explicit overrides take priority" {
+ const allocator = std.testing.allocator;
+ const fallback = try loadConfig(allocator, null, null, default_oauth_client_id);
+ defer allocator.free(fallback);
+ try std.testing.expectEqualStrings(default_oauth_client_id, fallback);
+
+ const override = try loadConfig(allocator, " configured-id \n", "/nonexistent", default_oauth_client_id);
+ defer allocator.free(override);
+ try std.testing.expectEqualStrings("configured-id", override);
+ try std.testing.expectError(error.InvalidConfig, loadConfig(allocator, " \n", null, default_oauth_client_id));
+ try std.testing.expectError(error.FileNotFound, loadConfig(allocator, null, "/nonexistent", default_oauth_client_id));
+}
+
+test "service identifier files are trimmed and cannot silently replace invalid values with defaults" {
+ const allocator = std.testing.allocator;
+ var directory = std.testing.tmpDir(.{});
+ defer directory.cleanup();
+ try directory.dir.writeFile(.{ .sub_path = "client-id", .data = " file-client-id\r\n" });
+ const path = try directory.dir.realpathAlloc(allocator, "client-id");
+ defer allocator.free(path);
+ const value = try loadConfig(allocator, null, path, default_oauth_client_id);
+ defer allocator.free(value);
+ try std.testing.expectEqualStrings("file-client-id", value);
+ try directory.dir.writeFile(.{ .sub_path = "client-id", .data = " \n" });
+ try std.testing.expectError(error.InvalidConfig, loadConfig(allocator, null, path, default_oauth_client_id));
+ try directory.dir.writeFile(.{ .sub_path = "client-id", .data = &([_]u8{'x'} ** (max_config_length + 1)) });
+ try std.testing.expectError(error.FileTooBig, loadConfig(allocator, null, path, default_oauth_client_id));
+}
+
+test "validates stable device identifiers" {
+ try std.testing.expect(uuid.valid("12345678-1234-4abc-8def-123456789abc"));
+ try std.testing.expect(!uuid.valid("12345678-1234-4abc-8def"));
+}
+
+test "account lookup preserves cancellation consumed by the transfer callback" {
+ const http = @import("gfn_http_fake");
+ http.reset();
+ const client = http.authClient(Client);
+ try std.testing.expectError(error.Cancelled, client.fetchUserAge());
+ try std.testing.expectEqual(@as(usize, 1), http.requests);
+ try std.testing.expectEqual(@as(usize, 1), http.polls);
+}
diff --git a/src/provider/geforce_now/auth_protocol.zig b/src/provider/geforce_now/auth_protocol.zig
new file mode 100644
index 0000000..53ae699
--- /dev/null
+++ b/src/provider/geforce_now/auth_protocol.zig
@@ -0,0 +1,586 @@
+const std = @import("std");
+const form = @import("form_writer");
+
+pub const device_authorization_endpoint = "https://login.nvidia.com/device/authorize";
+pub const token_endpoint = "https://login.nvidia.com/token";
+pub const client_token_endpoint = "https://login.nvidia.com/client_token";
+pub const user_info_endpoint = "https://login.nvidia.com/userinfo";
+pub const scope = "openid consent email tk_client age";
+pub const token_refresh_window_seconds: i64 = 10 * 60;
+
+const default_token_lifetime_seconds: u32 = 24 * 60 * 60;
+
+pub const DeviceAuthorization = struct {
+ client_id: []const u8,
+ device_id: []const u8,
+ display_name: []const u8,
+ idp_id: ?[]const u8 = null,
+};
+
+pub const DeviceChallenge = struct {
+ allocator: std.mem.Allocator,
+ device_code: []u8,
+ user_code: []u8,
+ verification_uri: []u8,
+ verification_uri_complete: ?[]u8,
+ expires_in: u32,
+ interval: u32,
+
+ pub fn secondsRemaining(self: *const DeviceChallenge, started: u32, now: u32) u32 {
+ return self.expires_in -| ((now -% started) / 1000);
+ }
+
+ pub fn deinit(self: *DeviceChallenge) void {
+ secureFree(self.allocator, self.device_code);
+ secureFree(self.allocator, self.user_code);
+ self.allocator.free(self.verification_uri);
+ if (self.verification_uri_complete) |uri| self.allocator.free(uri);
+ self.* = undefined;
+ }
+};
+
+pub const Tokens = struct {
+ allocator: std.mem.Allocator,
+ access_token: []u8,
+ refresh_token: ?[]u8,
+ id_token: ?[]u8,
+ client_token: ?[]u8,
+ expires_at: i64,
+
+ pub fn deinit(self: *Tokens) void {
+ secureFree(self.allocator, self.access_token);
+ if (self.refresh_token) |token| secureFree(self.allocator, token);
+ if (self.id_token) |token| secureFree(self.allocator, token);
+ if (self.client_token) |token| secureFree(self.allocator, token);
+ self.* = undefined;
+ }
+
+ pub fn bearer(self: *const Tokens) []const u8 {
+ return self.id_token orelse self.access_token;
+ }
+
+ pub fn needsRefresh(self: *const Tokens, now: i64) bool {
+ if (now < 0 or self.expires_at <= now) return true;
+ return self.expires_at - now <= token_refresh_window_seconds;
+ }
+};
+
+pub const PollError = enum {
+ authorization_pending,
+ slow_down,
+ expired_token,
+ access_denied,
+ invalid_grant,
+ other,
+};
+
+pub fn buildDeviceAuthorizationForm(
+ request: DeviceAuthorization,
+ output: []u8,
+) ![:0]u8 {
+ if (request.idp_id) |idp_id| {
+ return form.build(&.{
+ .{ .name = "client_id", .value = request.client_id },
+ .{ .name = "scope", .value = scope },
+ .{ .name = "device_id", .value = request.device_id },
+ .{ .name = "display_name", .value = request.display_name },
+ .{ .name = "idp_id", .value = idp_id },
+ }, output);
+ }
+ return form.build(&.{
+ .{ .name = "client_id", .value = request.client_id },
+ .{ .name = "scope", .value = scope },
+ .{ .name = "device_id", .value = request.device_id },
+ .{ .name = "display_name", .value = request.display_name },
+ }, output);
+}
+
+pub fn buildDeviceTokenForm(
+ client_id: []const u8,
+ device_code: []const u8,
+ output: []u8,
+) ![:0]u8 {
+ return form.build(&.{
+ .{ .name = "client_id", .value = client_id },
+ .{ .name = "device_code", .value = device_code },
+ .{ .name = "grant_type", .value = "urn:ietf:params:oauth:grant-type:device_code" },
+ }, output);
+}
+
+pub fn buildRefreshTokenForm(
+ client_id: []const u8,
+ refresh_token: []const u8,
+ output: []u8,
+) ![:0]u8 {
+ return form.build(&.{
+ .{ .name = "client_id", .value = client_id },
+ .{ .name = "grant_type", .value = "refresh_token" },
+ .{ .name = "refresh_token", .value = refresh_token },
+ .{ .name = "scope", .value = scope },
+ }, output);
+}
+
+pub fn buildClientTokenForm(
+ client_id: []const u8,
+ client_token: []const u8,
+ subject: []const u8,
+ output: []u8,
+) ![:0]u8 {
+ return form.build(&.{
+ .{ .name = "client_id", .value = client_id },
+ .{ .name = "grant_type", .value = "urn:ietf:params:oauth:grant-type:client_token" },
+ .{ .name = "client_token", .value = client_token },
+ .{ .name = "sub", .value = subject },
+ }, output);
+}
+
+pub fn parseDeviceChallenge(
+ allocator: std.mem.Allocator,
+ data: []const u8,
+) !DeviceChallenge {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const object = try rootObject(parsed.value);
+
+ const device_code = try duplicateRequiredString(allocator, object, "device_code");
+ errdefer secureFree(allocator, device_code);
+ const user_code = try duplicateRequiredString(allocator, object, "user_code");
+ errdefer secureFree(allocator, user_code);
+ const verification_uri = try duplicateRequiredString(allocator, object, "verification_uri");
+ errdefer allocator.free(verification_uri);
+ const verification_uri_complete = try duplicateOptionalString(
+ allocator,
+ object,
+ "verification_uri_complete",
+ );
+ errdefer if (verification_uri_complete) |uri| allocator.free(uri);
+
+ return .{
+ .allocator = allocator,
+ .device_code = device_code,
+ .user_code = user_code,
+ .verification_uri = verification_uri,
+ .verification_uri_complete = verification_uri_complete,
+ .expires_in = try requiredUnsigned(object, "expires_in"),
+ .interval = optionalUnsigned(object, "interval") orelse 5,
+ };
+}
+
+pub fn parseGrantTokens(
+ allocator: std.mem.Allocator,
+ data: []const u8,
+ issued_at: i64,
+) !Tokens {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const object = try rootObject(parsed.value);
+
+ const lifetime = try tokenLifetime(object);
+ return parseTokensObject(allocator, object, try expiryFromLifetime(issued_at, lifetime));
+}
+
+pub fn parseStoredTokens(allocator: std.mem.Allocator, data: []const u8) !Tokens {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const object = try rootObject(parsed.value);
+ return parseTokensObject(allocator, object, try requiredSigned(object, "expires_at"));
+}
+
+fn parseTokensObject(
+ allocator: std.mem.Allocator,
+ object: std.json.ObjectMap,
+ expires_at: i64,
+) !Tokens {
+ const access_token = try duplicateRequiredString(allocator, object, "access_token");
+ errdefer secureFree(allocator, access_token);
+ const refresh_token = try duplicateOptionalString(allocator, object, "refresh_token");
+ errdefer if (refresh_token) |token| secureFree(allocator, token);
+ const id_token = try duplicateOptionalString(allocator, object, "id_token");
+ errdefer if (id_token) |token| secureFree(allocator, token);
+ const client_token = try duplicateOptionalString(allocator, object, "client_token");
+ errdefer if (client_token) |token| secureFree(allocator, token);
+
+ return .{
+ .allocator = allocator,
+ .access_token = access_token,
+ .refresh_token = refresh_token,
+ .id_token = id_token,
+ .client_token = client_token,
+ .expires_at = expires_at,
+ };
+}
+
+pub fn parseClientToken(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ return duplicateRequiredString(allocator, try rootObject(parsed.value), "client_token");
+}
+
+pub fn jwtSubject(allocator: std.mem.Allocator, token: []const u8) ![]u8 {
+ var parts = std.mem.splitScalar(u8, token, '.');
+ _ = parts.next() orelse return error.InvalidJwt;
+ const encoded = parts.next() orelse return error.InvalidJwt;
+ _ = parts.next() orelse return error.InvalidJwt;
+ if (parts.next() != null or encoded.len == 0) return error.InvalidJwt;
+
+ const decoded_length = try std.base64.url_safe_no_pad.Decoder.calcSizeForSlice(encoded);
+ const decoded = try allocator.alloc(u8, decoded_length);
+ defer allocator.free(decoded);
+ try std.base64.url_safe_no_pad.Decoder.decode(decoded, encoded);
+
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, decoded, .{});
+ defer parsed.deinit();
+ return duplicateRequiredString(allocator, try rootObject(parsed.value), "sub");
+}
+
+pub fn parseUserAge(allocator: std.mem.Allocator, data: []const u8, subject: []const u8) !u8 {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const object = try rootObject(parsed.value);
+ const account = object.get("sub") orelse return error.MissingSubject;
+ if (account != .string or !std.mem.eql(u8, account.string, subject))
+ return error.AccountMismatch;
+ const age = object.get("age") orelse return error.MissingUserAge;
+ if (age != .integer) return error.InvalidUserAge;
+ return std.math.cast(u8, age.integer) orelse error.InvalidUserAge;
+}
+
+test "reads account age without substituting a default" {
+ for ([_]u8{ 0, 17, 37, 100 }) |age| {
+ var buffer: [64]u8 = undefined;
+ const data = try std.fmt.bufPrint(&buffer, "{{\"sub\":\"user-one\",\"age\":{d}}}", .{age});
+ try std.testing.expectEqual(age, try parseUserAge(std.testing.allocator, data, "user-one"));
+ }
+ try std.testing.expectError(error.MissingUserAge, parseUserAge(std.testing.allocator, "{\"sub\":\"user-one\"}", "user-one"));
+ try std.testing.expectError(error.AccountMismatch, parseUserAge(std.testing.allocator, "{\"sub\":\"user-two\",\"age\":37}", "user-one"));
+ for ([_][]const u8{ "-1", "256", "null", "true", "37.5", "\"37\"" }) |value| {
+ var buffer: [64]u8 = undefined;
+ const data = try std.fmt.bufPrint(&buffer, "{{\"sub\":\"user-one\",\"age\":{s}}}", .{value});
+ try std.testing.expectError(error.InvalidUserAge, parseUserAge(std.testing.allocator, data, "user-one"));
+ }
+}
+
+pub fn mergeRefreshedTokens(
+ allocator: std.mem.Allocator,
+ previous: *const Tokens,
+ data: []const u8,
+ issued_at: i64,
+) !Tokens {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const object = try rootObject(parsed.value);
+
+ const access_token = try duplicateRequiredString(allocator, object, "access_token");
+ errdefer secureFree(allocator, access_token);
+ const refresh_token = try duplicateResponseOrPrevious(
+ allocator,
+ object,
+ "refresh_token",
+ previous.refresh_token,
+ );
+ errdefer if (refresh_token) |token| secureFree(allocator, token);
+ const id_token = try duplicateOptionalString(allocator, object, "id_token");
+ errdefer if (id_token) |token| secureFree(allocator, token);
+ const client_token = try duplicateResponseOrPrevious(
+ allocator,
+ object,
+ "client_token",
+ previous.client_token,
+ );
+ errdefer if (client_token) |token| secureFree(allocator, token);
+ const lifetime = try tokenLifetime(object);
+
+ return .{
+ .allocator = allocator,
+ .access_token = access_token,
+ .refresh_token = refresh_token,
+ .id_token = id_token,
+ .client_token = client_token,
+ .expires_at = try expiryFromLifetime(issued_at, lifetime),
+ };
+}
+
+pub fn classifyPollError(allocator: std.mem.Allocator, data: []const u8) !PollError {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const object = try rootObject(parsed.value);
+ const code = try requiredString(object, "error");
+
+ if (std.mem.eql(u8, code, "authorization_pending")) return .authorization_pending;
+ if (std.mem.eql(u8, code, "slow_down")) return .slow_down;
+ if (std.mem.eql(u8, code, "expired_token")) return .expired_token;
+ if (std.mem.eql(u8, code, "access_denied")) return .access_denied;
+ if (std.mem.eql(u8, code, "invalid_grant")) return .invalid_grant;
+ return .other;
+}
+
+pub fn writePollErrorCode(
+ allocator: std.mem.Allocator,
+ data: []const u8,
+ output: []u8,
+) ?[]const u8 {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return null;
+ defer parsed.deinit();
+ const code = requiredString(rootObject(parsed.value) catch return null, "error") catch return null;
+ if (code.len == 0 or code.len > output.len) return null;
+ for (code, 0..) |byte, index| {
+ if (!std.ascii.isAlphanumeric(byte) and byte != '_' and byte != '-' and byte != '.')
+ return null;
+ output[index] = byte;
+ }
+ return output[0..code.len];
+}
+
+fn rootObject(value: std.json.Value) !std.json.ObjectMap {
+ return switch (value) {
+ .object => |object| object,
+ else => error.ExpectedObject,
+ };
+}
+
+fn requiredString(object: std.json.ObjectMap, name: []const u8) ![]const u8 {
+ const value = object.get(name) orelse return error.MissingField;
+ return switch (value) {
+ .string => |text| if (text.len == 0) error.MissingField else text,
+ else => error.InvalidField,
+ };
+}
+
+fn duplicateRequiredString(
+ allocator: std.mem.Allocator,
+ object: std.json.ObjectMap,
+ name: []const u8,
+) ![]u8 {
+ return allocator.dupe(u8, try requiredString(object, name));
+}
+
+fn duplicateOptionalString(
+ allocator: std.mem.Allocator,
+ object: std.json.ObjectMap,
+ name: []const u8,
+) !?[]u8 {
+ const value = object.get(name) orelse return null;
+ const text = switch (value) {
+ .string => |candidate| candidate,
+ .null => return null,
+ else => return error.InvalidField,
+ };
+ if (text.len == 0) return null;
+ return try allocator.dupe(u8, text);
+}
+
+fn duplicateResponseOrPrevious(
+ allocator: std.mem.Allocator,
+ object: std.json.ObjectMap,
+ name: []const u8,
+ previous: ?[]const u8,
+) !?[]u8 {
+ if (try duplicateOptionalString(allocator, object, name)) |value| return value;
+ if (previous) |value| return try allocator.dupe(u8, value);
+ return null;
+}
+
+fn requiredUnsigned(object: std.json.ObjectMap, name: []const u8) !u32 {
+ return optionalUnsigned(object, name) orelse error.MissingField;
+}
+
+fn requiredSigned(object: std.json.ObjectMap, name: []const u8) !i64 {
+ const value = object.get(name) orelse return error.MissingField;
+ return switch (value) {
+ .integer => |number| number,
+ else => error.InvalidField,
+ };
+}
+
+fn optionalUnsigned(object: std.json.ObjectMap, name: []const u8) ?u32 {
+ const value = object.get(name) orelse return null;
+ return switch (value) {
+ .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32))
+ @intCast(number)
+ else
+ null,
+ else => null,
+ };
+}
+
+fn tokenLifetime(object: std.json.ObjectMap) !u32 {
+ if (!object.contains("expires_in")) return default_token_lifetime_seconds;
+ return optionalUnsigned(object, "expires_in") orelse error.InvalidField;
+}
+
+fn secureFree(allocator: std.mem.Allocator, value: []u8) void {
+ std.crypto.secureZero(u8, value);
+ allocator.free(value);
+}
+
+fn expiryFromLifetime(issued_at: i64, lifetime: u32) !i64 {
+ if (issued_at < 0) return error.InvalidTimestamp;
+ return std.math.add(i64, issued_at, @as(i64, lifetime)) catch error.InvalidTimestamp;
+}
+
+test "builds NVIDIA device authorization and token forms" {
+ var output: [512]u8 = undefined;
+ const authorization = try buildDeviceAuthorizationForm(.{
+ .client_id = "client id",
+ .device_id = "device/1",
+ .display_name = "GreenOvercast",
+ .idp_id = "provider",
+ }, &output);
+ try std.testing.expectEqualStrings(
+ "client_id=client+id&scope=openid+consent+email+tk_client+age&device_id=device%2F1&display_name=GreenOvercast&idp_id=provider",
+ authorization,
+ );
+
+ const token = try buildDeviceTokenForm("client", "code+value", &output);
+ try std.testing.expectEqualStrings(
+ "client_id=client&device_code=code%2Bvalue&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
+ token,
+ );
+
+ const client_token = try buildClientTokenForm("client", "device token", "user/id", &output);
+ try std.testing.expectEqualStrings(
+ "client_id=client&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Aclient_token&client_token=device+token&sub=user%2Fid",
+ client_token,
+ );
+}
+
+test "parses a device challenge" {
+ var challenge = try parseDeviceChallenge(std.testing.allocator,
+ \\{"device_code":"secret","user_code":"ABCD-EFGH","verification_uri":"https://example.test/link","expires_in":900}
+ );
+ defer challenge.deinit();
+
+ try std.testing.expectEqualStrings("secret", challenge.device_code);
+ try std.testing.expectEqualStrings("ABCD-EFGH", challenge.user_code);
+ try std.testing.expectEqualStrings("https://example.test/link", challenge.verification_uri);
+ try std.testing.expectEqual(@as(u32, 900), challenge.expires_in);
+ try std.testing.expectEqual(@as(u32, 5), challenge.interval);
+}
+
+test "device challenge expiry handles delayed draws and tick wrap" {
+ var challenge = try parseDeviceChallenge(std.testing.allocator,
+ \\{"device_code":"test","user_code":"1234","verification_uri":"https://example.test/link","expires_in":2,"interval":5}
+ );
+ defer challenge.deinit();
+ const started: u32 = std.math.maxInt(u32) - 500;
+ try std.testing.expectEqual(@as(u32, 2), challenge.secondsRemaining(started, started));
+ try std.testing.expectEqual(@as(u32, 1), challenge.secondsRemaining(started, started +% 1000));
+ try std.testing.expectEqual(@as(u32, 0), challenge.secondsRemaining(started, started +% 2000));
+ try std.testing.expectEqual(@as(u32, 0), challenge.secondsRemaining(started, started +% 4000));
+}
+
+test "refresh retains renewal credentials but never reuses an old bearer" {
+ var previous = try parseGrantTokens(
+ std.testing.allocator,
+ "{\"access_token\":\"access-one\",\"refresh_token\":\"refresh-one\",\"id_token\":\"id-one\",\"client_token\":\"client-one\",\"expires_in\":3600}",
+ 1_000,
+ );
+ defer previous.deinit();
+
+ var refreshed = try mergeRefreshedTokens(
+ std.testing.allocator,
+ &previous,
+ "{\"access_token\":\"access-two\",\"expires_in\":7200}",
+ 2_000,
+ );
+ defer refreshed.deinit();
+
+ try std.testing.expectEqualStrings("access-two", refreshed.access_token);
+ try std.testing.expectEqualStrings("refresh-one", refreshed.refresh_token.?);
+ try std.testing.expect(refreshed.id_token == null);
+ try std.testing.expectEqualStrings("client-one", refreshed.client_token.?);
+ try std.testing.expectEqualStrings("access-two", refreshed.bearer());
+ try std.testing.expectEqual(@as(i64, 9_200), refreshed.expires_at);
+}
+
+test "stored tokens retain their absolute expiry" {
+ var tokens = try parseStoredTokens(std.testing.allocator,
+ \\{"access_token":"access","expires_at":1234567890}
+ );
+ defer tokens.deinit();
+
+ try std.testing.expectEqual(@as(i64, 1_234_567_890), tokens.expires_at);
+ try std.testing.expect(!tokens.needsRefresh(1_234_566_000));
+ try std.testing.expect(tokens.needsRefresh(1_234_567_400));
+ try std.testing.expect(tokens.needsRefresh(1_234_567_890));
+}
+
+test "token lifetime defaults only when omitted in grant and refresh responses" {
+ var previous = try parseGrantTokens(std.testing.allocator, "{\"access_token\":\"old\"}", 1000);
+ defer previous.deinit();
+ try std.testing.expectEqual(1000 + @as(i64, default_token_lifetime_seconds), previous.expires_at);
+
+ var refreshed = try mergeRefreshedTokens(std.testing.allocator, &previous, "{\"access_token\":\"new\"}", 2000);
+ defer refreshed.deinit();
+ try std.testing.expectEqual(2000 + @as(i64, default_token_lifetime_seconds), refreshed.expires_at);
+
+ for ([_]u32{ 0, 3600, std.math.maxInt(u32) }) |lifetime| {
+ var buffer: [128]u8 = undefined;
+ const data = try std.fmt.bufPrint(&buffer, "{{\"access_token\":\"new\",\"expires_in\":{d}}}", .{lifetime});
+ var grant = try parseGrantTokens(std.testing.allocator, data, 2000);
+ defer grant.deinit();
+ var refresh = try mergeRefreshedTokens(std.testing.allocator, &previous, data, 2000);
+ defer refresh.deinit();
+ try std.testing.expectEqual(2000 + @as(i64, lifetime), grant.expires_at);
+ try std.testing.expectEqual(grant.expires_at, refresh.expires_at);
+ }
+ for ([_][]const u8{ "-1", "4294967296", "3600.5", "null", "true", "\"3600\"" }) |value| {
+ var buffer: [128]u8 = undefined;
+ const data = try std.fmt.bufPrint(&buffer, "{{\"access_token\":\"new\",\"expires_in\":{s}}}", .{value});
+ try std.testing.expectError(error.InvalidField, parseGrantTokens(std.testing.allocator, data, 2000));
+ try std.testing.expectError(error.InvalidField, mergeRefreshedTokens(std.testing.allocator, &previous, data, 2000));
+ }
+}
+
+test "stored tokens require an expiry" {
+ try std.testing.expectError(
+ error.MissingField,
+ parseStoredTokens(std.testing.allocator, "{\"access_token\":\"access\"}"),
+ );
+}
+
+test "rejects incomplete responses and classifies polling errors" {
+ try std.testing.expectError(
+ error.MissingField,
+ parseDeviceChallenge(std.testing.allocator, "{\"user_code\":\"ABCD\"}"),
+ );
+ try std.testing.expectEqual(
+ PollError.authorization_pending,
+ try classifyPollError(std.testing.allocator, "{\"error\":\"authorization_pending\"}"),
+ );
+ try std.testing.expectEqual(
+ PollError.other,
+ try classifyPollError(std.testing.allocator, "{\"error\":\"server_error\"}"),
+ );
+
+ var error_buffer: [32]u8 = undefined;
+ try std.testing.expectEqualStrings(
+ "invalid_client",
+ writePollErrorCode(
+ std.testing.allocator,
+ "{\"error\":\"invalid_client\",\"error_description\":\"details omitted\"}",
+ &error_buffer,
+ ).?,
+ );
+ try std.testing.expect(writePollErrorCode(
+ std.testing.allocator,
+ "{\"error\":\"unsafe value\"}",
+ &error_buffer,
+ ) == null);
+}
+
+test "parses a client token and JWT subject" {
+ const client_token = try parseClientToken(
+ std.testing.allocator,
+ "{\"client_token\":\"device-credential\",\"expires_in\":86400}",
+ );
+ defer std.testing.allocator.free(client_token);
+ try std.testing.expectEqualStrings("device-credential", client_token);
+
+ const subject = try jwtSubject(
+ std.testing.allocator,
+ "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature",
+ );
+ defer std.testing.allocator.free(subject);
+ try std.testing.expectEqualStrings("user-123", subject);
+}
diff --git a/src/provider/geforce_now/catalog_protocol.zig b/src/provider/geforce_now/catalog_protocol.zig
new file mode 100644
index 0000000..f489956
--- /dev/null
+++ b/src/provider/geforce_now/catalog_protocol.zig
@@ -0,0 +1,355 @@
+const std = @import("std");
+const catalog = @import("catalog_parser");
+
+pub const endpoint = "https://games.geforce.com/graphql";
+pub const page_size: u32 = 200;
+
+const LibraryFilters = struct {
+ variants: struct {
+ gfn: struct {
+ library: struct {
+ status: struct {
+ notEquals: []const u8,
+ },
+ },
+ },
+ },
+};
+
+const library_filters = LibraryFilters{
+ .variants = .{
+ .gfn = .{
+ .library = .{
+ .status = .{ .notEquals = "NOT_OWNED" },
+ },
+ },
+ },
+};
+
+const query =
+ \\query GetCatalogApps($vpcId:String!,$locale:String!,$sortString:String!,$fetchCount:Int!,$cursor:String!,$filters:AppFilterFields!){
+ \\ apps(vpcId:$vpcId,language:$locale,orderBy:$sortString,first:$fetchCount,after:$cursor,filters:$filters){
+ \\ items{id title variants{id appStore gfn{status library{status selected}}} images{GAME_BOX_ART KEY_IMAGE KEY_ART}}
+ \\ pageInfo{hasNextPage endCursor totalCount}
+ \\ }
+ \\}
+;
+
+pub const Page = struct {
+ allocator: std.mem.Allocator,
+ titles: []catalog.Title,
+ next_cursor: ?[]u8,
+ total_count: ?usize,
+
+ pub fn deinit(self: *Page) void {
+ self.allocator.free(self.titles);
+ if (self.next_cursor) |cursor| self.allocator.free(cursor);
+ self.* = undefined;
+ }
+};
+
+pub fn buildRequest(
+ allocator: std.mem.Allocator,
+ vpc_id: []const u8,
+ cursor: []const u8,
+) ![]u8 {
+ if (vpc_id.len == 0) return error.InvalidVpcId;
+ return std.json.stringifyAlloc(allocator, .{
+ .query = query,
+ .variables = .{
+ .vpcId = vpc_id,
+ .locale = "en_US",
+ .sortString = "variants.gfn.library.lastPlayedDate:DESC,computedValues.libraryAddedDate:DESC,sortName:ASC",
+ .fetchCount = page_size,
+ .cursor = cursor,
+ .filters = library_filters,
+ },
+ }, .{});
+}
+
+pub fn parsePage(allocator: std.mem.Allocator, data: []const u8) !Page {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const root = try object(parsed.value);
+ if (root.get("errors")) |errors| {
+ const values = try array(errors);
+ if (values.items.len > 0) return error.GraphQlRejected;
+ }
+ const response_data = try object(root.get("data") orelse return error.MissingData);
+ const apps = try object(response_data.get("apps") orelse return error.MissingApps);
+ const items = try array(apps.get("items") orelse return error.MissingItems);
+
+ var titles = std.ArrayList(catalog.Title).init(allocator);
+ errdefer titles.deinit();
+ for (items.items) |value| {
+ const item = object(value) catch continue;
+ const name = optionalString(item, "title") catch continue orelse continue;
+ var title = std.mem.zeroes(catalog.Title);
+ const launch_id = launchId(item) catch continue orelse continue;
+ if (!writeIdentifier(&title.title_id, launch_id)) continue;
+ if (item.get("id")) |stable_id| {
+ if (!writeIdentifier(&title.product_id, stable_id))
+ _ = catalog.writeCString(&title.product_id, catalog.cString(&title.title_id));
+ } else {
+ _ = catalog.writeCString(&title.product_id, catalog.cString(&title.title_id));
+ }
+ _ = catalog.writeDisplayCString(&title.name, name);
+ if (artworkUrl(item)) |url| _ = writeArtwork(&title.artwork_url, url);
+ try titles.append(title);
+ }
+
+ var next_cursor: ?[]u8 = null;
+ var total_count: ?usize = null;
+ if (apps.get("pageInfo")) |page_info_value| {
+ const page_info = try object(page_info_value);
+ total_count = optionalUnsigned(page_info, "totalCount");
+ if (optionalBool(page_info, "hasNextPage") orelse false) {
+ if (try optionalString(page_info, "endCursor")) |cursor| {
+ if (cursor.len > 0) next_cursor = try allocator.dupe(u8, cursor);
+ }
+ }
+ }
+ errdefer if (next_cursor) |cursor| allocator.free(cursor);
+
+ return .{
+ .allocator = allocator,
+ .titles = try titles.toOwnedSlice(),
+ .next_cursor = next_cursor,
+ .total_count = total_count,
+ };
+}
+
+pub fn writeErrorSummary(
+ allocator: std.mem.Allocator,
+ data: []const u8,
+ output: []u8,
+) ?[]const u8 {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return null;
+ defer parsed.deinit();
+ const root = object(parsed.value) catch return null;
+ const errors = array(root.get("errors") orelse return null) catch return null;
+ if (errors.items.len == 0) return null;
+ const first = object(errors.items[0]) catch return null;
+ const message = (optionalString(first, "message") catch return null) orelse return null;
+ const length = @min(message.len, output.len);
+ for (message[0..length], 0..) |byte, index| {
+ output[index] = if (byte >= 0x20 and byte != 0x7f) byte else ' ';
+ }
+ return output[0..length];
+}
+
+fn launchId(item: std.json.ObjectMap) !?std.json.Value {
+ if (item.get("variants")) |variants_value| {
+ const variants = try array(variants_value);
+ for (variants.items) |variant_value| {
+ const variant = object(variant_value) catch continue;
+ const value = variant.get("id") orelse continue;
+ if (numericIdentifier(value) and selectedOwnedLibraryVariant(variant)) return value;
+ }
+ for (variants.items) |variant_value| {
+ const variant = object(variant_value) catch continue;
+ const value = variant.get("id") orelse continue;
+ if (numericIdentifier(value) and ownedLibraryVariant(variant)) return value;
+ }
+ }
+ return null;
+}
+
+fn selectedOwnedLibraryVariant(variant: std.json.ObjectMap) bool {
+ const gfn = object(variant.get("gfn") orelse return false) catch return false;
+ const library = object(gfn.get("library") orelse return false) catch return false;
+ return (optionalBool(library, "selected") orelse false) and ownedLibraryStatus(library);
+}
+
+fn ownedLibraryVariant(variant: std.json.ObjectMap) bool {
+ const gfn = object(variant.get("gfn") orelse return false) catch return false;
+ const library = object(gfn.get("library") orelse return false) catch return false;
+ return ownedLibraryStatus(library);
+}
+
+fn ownedLibraryStatus(library: std.json.ObjectMap) bool {
+ const status = (optionalString(library, "status") catch return false) orelse return false;
+ return std.mem.eql(u8, status, "MANUAL") or
+ std.mem.eql(u8, status, "PLATFORM_SYNC") or
+ std.mem.eql(u8, status, "IN_LIBRARY");
+}
+
+fn artworkUrl(item: std.json.ObjectMap) ?[]const u8 {
+ const images = object(item.get("images") orelse return null) catch return null;
+ for ([_][]const u8{ "GAME_BOX_ART", "KEY_IMAGE", "KEY_ART" }) |key| {
+ const value = images.get(key) orelse continue;
+ switch (value) {
+ .string => |text| if (text.len > 0) return text,
+ .array => |values| if (values.items.len > 0) switch (values.items[0]) {
+ .string => |text| if (text.len > 0) return text,
+ else => {},
+ },
+ else => {},
+ }
+ }
+ return null;
+}
+
+fn writeArtwork(output: []u8, url: []const u8) bool {
+ if (!std.mem.startsWith(u8, url, "https://")) return false;
+ const suffix = if (std.mem.indexOf(u8, url, "img.nvidiagrid.net") != null)
+ ";f=jpeg;w=256"
+ else
+ "";
+ if (url.len + suffix.len >= output.len) return false;
+ @memset(output, 0);
+ @memcpy(output[0..url.len], url);
+ @memcpy(output[url.len..][0..suffix.len], suffix);
+ return true;
+}
+
+fn numericIdentifier(value: std.json.Value) bool {
+ return switch (value) {
+ .integer => |number| number >= 0,
+ .string => |text| numeric: {
+ if (text.len == 0) break :numeric false;
+ for (text) |byte| if (!std.ascii.isDigit(byte)) break :numeric false;
+ break :numeric true;
+ },
+ else => false,
+ };
+}
+
+fn writeIdentifier(output: []u8, value: std.json.Value) bool {
+ return switch (value) {
+ .string => |text| catalog.writeCString(output, text),
+ .integer => |number| if (number >= 0) blk: {
+ var buffer: [32]u8 = undefined;
+ const text = std.fmt.bufPrint(&buffer, "{d}", .{number}) catch break :blk false;
+ break :blk catalog.writeCString(output, text);
+ } else false,
+ else => false,
+ };
+}
+
+fn object(value: std.json.Value) !std.json.ObjectMap {
+ return switch (value) {
+ .object => |result| result,
+ else => error.ExpectedObject,
+ };
+}
+
+fn array(value: std.json.Value) !std.json.Array {
+ return switch (value) {
+ .array => |result| result,
+ else => error.ExpectedArray,
+ };
+}
+
+fn optionalString(value: std.json.ObjectMap, key: []const u8) !?[]const u8 {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .string => |text| if (text.len > 0) text else null,
+ .null => null,
+ else => error.InvalidField,
+ };
+}
+
+fn optionalUnsigned(value: std.json.ObjectMap, key: []const u8) ?usize {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .integer => |number| if (number >= 0 and number <= std.math.maxInt(usize)) @intCast(number) else null,
+ else => null,
+ };
+}
+
+fn optionalBool(value: std.json.ObjectMap, key: []const u8) ?bool {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .bool => |enabled| enabled,
+ else => null,
+ };
+}
+
+test "builds a paged catalog request" {
+ const request = try buildRequest(std.testing.allocator, "GFN-PC", "cursor/value");
+ defer std.testing.allocator.free(request);
+ try std.testing.expect(std.mem.indexOf(u8, request, "\"vpcId\":\"GFN-PC\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, request, "\"cursor\":\"cursor/value\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, request, "\"notEquals\":\"NOT_OWNED\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, request, "GAME_BOX_ART") != null);
+ try std.testing.expect(std.mem.indexOf(u8, request, "library{status selected}") != null);
+}
+
+test "parses launch variants artwork and pagination" {
+ var page = try parsePage(std.testing.allocator,
+ \\{"data":{"apps":{"items":[
+ \\ {"id":"game-one","title":"Fixture Racer","variants":[{"id":"variant-name"},{"id":"202","gfn":{"library":{"status":"MANUAL"}}}],"images":{"GAME_BOX_ART":"https://img.nvidiagrid.net/box"}},
+ \\ {"id":303,"title":"Second Game","variants":[{"id":303,"gfn":{"library":{"status":"IN_LIBRARY"}}}],"images":{"KEY_IMAGE":["https://example.invalid/key.jpg"]}}
+ \\],"pageInfo":{"hasNextPage":true,"endCursor":"next","totalCount":402}}}}
+ );
+ defer page.deinit();
+
+ try std.testing.expectEqual(@as(usize, 2), page.titles.len);
+ try std.testing.expectEqualStrings("202", catalog.cString(&page.titles[0].title_id));
+ try std.testing.expectEqualStrings("game-one", catalog.cString(&page.titles[0].product_id));
+ try std.testing.expectEqualStrings("Fixture Racer", catalog.cString(&page.titles[0].name));
+ try std.testing.expectEqualStrings(
+ "https://img.nvidiagrid.net/box;f=jpeg;w=256",
+ catalog.cString(&page.titles[0].artwork_url),
+ );
+ try std.testing.expectEqualStrings("303", catalog.cString(&page.titles[1].title_id));
+ try std.testing.expectEqualStrings("next", page.next_cursor.?);
+ try std.testing.expectEqual(@as(?usize, 402), page.total_count);
+}
+
+test "prefers the account-selected numeric launch variant" {
+ var page = try parsePage(std.testing.allocator,
+ \\{"data":{"apps":{"items":[
+ \\ {"id":"game-one","title":"Fixture Racer","variants":[
+ \\ {"id":"202","appStore":"STEAM","gfn":{"library":{"status":"NOT_OWNED","selected":false}}},
+ \\ {"id":"303","appStore":"EPIC","gfn":{"library":{"status":"MANUAL","selected":true}}}
+ \\ ]}
+ \\],"pageInfo":{"hasNextPage":false}}}}
+ );
+ defer page.deinit();
+ try std.testing.expectEqualStrings("303", catalog.cString(&page.titles[0].title_id));
+}
+
+test "does not launch an unowned selected store variant" {
+ var page = try parsePage(std.testing.allocator,
+ \\{"data":{"apps":{"items":[
+ \\ {"id":"game-one","title":"Fixture Racer","variants":[
+ \\ {"id":"202","appStore":"STEAM","gfn":{"library":{"status":"NOT_OWNED","selected":true}}},
+ \\ {"id":"303","appStore":"XBOX","gfn":{"library":{"status":"PLATFORM_SYNC","selected":false}}}
+ \\ ]}
+ \\],"pageInfo":{"hasNextPage":false}}}}
+ );
+ defer page.deinit();
+ try std.testing.expectEqualStrings("303", catalog.cString(&page.titles[0].title_id));
+}
+
+test "rejects GraphQL errors" {
+ try std.testing.expectError(
+ error.GraphQlRejected,
+ parsePage(std.testing.allocator, "{\"errors\":[{\"message\":\"bad\"}]}"),
+ );
+}
+
+test "does not infer library ownership from a numeric id or missing status" {
+ var page = try parsePage(std.testing.allocator,
+ \\{"data":{"apps":{"items":[
+ \\ {"id":101,"title":"Unowned","variants":[{"id":101,"gfn":{"library":{"status":"NOT_OWNED","selected":true}}}]},
+ \\ {"id":202,"title":"Unknown","variants":[{"id":202,"gfn":{"library":{"selected":true}}}]},
+ \\ {"id":303,"title":"No variants","variants":[]},
+ \\ {"id":404,"title":"Invalid launch id","variants":[{"id":"not-numeric","gfn":{"library":{"status":"MANUAL"}}}]}
+ \\],"pageInfo":{"hasNextPage":false}}}}
+ );
+ defer page.deinit();
+ try std.testing.expectEqual(@as(usize, 0), page.titles.len);
+}
+
+test "writes a bounded single-line GraphQL error" {
+ var output: [18]u8 = undefined;
+ const summary = writeErrorSummary(
+ std.testing.allocator,
+ "{\"errors\":[{\"message\":\"Invalid query\\nwith details\"}]}",
+ &output,
+ ).?;
+ try std.testing.expectEqualStrings("Invalid query with", summary);
+}
diff --git a/src/provider/geforce_now/catalog_service.zig b/src/provider/geforce_now/catalog_service.zig
new file mode 100644
index 0000000..19d5949
--- /dev/null
+++ b/src/provider/geforce_now/catalog_service.zig
@@ -0,0 +1,339 @@
+const std = @import("std");
+const catalog = @import("catalog_parser");
+const auth_client = @import("auth_client.zig");
+const cloudmatch = @import("cloudmatch_protocol.zig");
+const protocol = @import("catalog_protocol.zig");
+
+const c = @cImport({
+ @cInclude("handheld_ui.h");
+ @cInclude("http_client.h");
+});
+
+const maximum_pages = 64;
+const maximum_bearer_length = 64 * 1024;
+
+pub const PickResult = union(enum) {
+ title: struct {
+ id: []const u8,
+ name: []const u8,
+ },
+ cancelled,
+ change_provider,
+ sign_out,
+};
+
+pub const Service = struct {
+ allocator: std.mem.Allocator,
+ auth: *auth_client.Client,
+ ui: *c.GoHandheldUi,
+ titles: ?[]catalog.Title = null,
+
+ pub fn create(
+ allocator: std.mem.Allocator,
+ auth: *auth_client.Client,
+ ui_pointer: *anyopaque,
+ ) !*Service {
+ const service = try allocator.create(Service);
+ service.* = .{
+ .allocator = allocator,
+ .auth = auth,
+ .ui = @ptrCast(ui_pointer),
+ };
+ return service;
+ }
+
+ pub fn destroy(self: *Service) void {
+ if (self.titles) |titles| self.allocator.free(titles);
+ const allocator = self.allocator;
+ allocator.destroy(self);
+ }
+
+ pub fn load(self: *Service) !void {
+ if (self.titles != null) return error.AlreadyLoaded;
+ const bearer = self.auth.bearer() orelse return error.MissingCredentials;
+ const streaming_url = self.auth.streamingUrl() orelse return error.MissingProvider;
+ const vpc_id = self.fetchVpcId(bearer, streaming_url) catch |err| {
+ std.debug.print("GeForce NOW server discovery failed: {s}\n", .{@errorName(err)});
+ return err;
+ };
+ defer self.allocator.free(vpc_id);
+
+ var loaded = std.ArrayList(catalog.Title).init(self.allocator);
+ errdefer loaded.deinit();
+ var cursor: ?[]u8 = null;
+ defer if (cursor) |value| self.allocator.free(value);
+ var page_index: usize = 0;
+ while (page_index < maximum_pages) : (page_index += 1) {
+ const body = try protocol.buildRequest(
+ self.allocator,
+ vpc_id,
+ cursor orelse "",
+ );
+ defer self.allocator.free(body);
+ const response = try self.catalogRequest(bearer, body);
+ defer c.go_http_response_destroy(response);
+ var page = try protocol.parsePage(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ );
+ defer page.deinit();
+ try loaded.appendSlice(page.titles);
+
+ var progress_buffer: [96]u8 = undefined;
+ const progress = try std.fmt.bufPrintZ(
+ &progress_buffer,
+ "{d} OF {d} GAMES",
+ .{ loaded.items.len, page.total_count orelse loaded.items.len },
+ );
+ c.go_handheld_ui_draw_loading(
+ self.ui,
+ "LOADING GEFORCE NOW",
+ progress.ptr,
+ c.GO_HANDHELD_UI_ACTION_BACK,
+ );
+ if (c.go_handheld_ui_cancel_requested(self.ui) != 0) return error.Cancelled;
+
+ if (cursor) |value| self.allocator.free(value);
+ cursor = null;
+ cursor = if (page.next_cursor) |value| try self.allocator.dupe(u8, value) else null;
+ if (cursor == null) break;
+ }
+ if (cursor != null) return error.CatalogPageLimit;
+ if (loaded.items.len == 0) return error.EmptyCatalog;
+
+ std.mem.sort(catalog.Title, loaded.items, {}, titleIdLessThan);
+ removeDuplicateLaunchIds(&loaded);
+ std.mem.sort(catalog.Title, loaded.items, {}, titleLessThan);
+ self.titles = try loaded.toOwnedSlice();
+ }
+
+ pub fn titleCount(self: *const Service) usize {
+ return if (self.titles) |titles| titles.len else 0;
+ }
+
+ pub fn pick(self: *Service, requested: []const u8) !PickResult {
+ const titles = self.titles orelse return error.NotLoaded;
+ if (titles.len == 0 or titles.len > std.math.maxInt(c_int)) return error.InvalidCatalog;
+ var requested_buffer: [128]u8 = [_]u8{0} ** 128;
+ if (requested.len >= requested_buffer.len) return error.InvalidRequestedTitle;
+ @memcpy(requested_buffer[0..requested.len], requested);
+ const selected = c.go_handheld_ui_pick_title(
+ self.ui,
+ @ptrCast(titles.ptr),
+ @intCast(titles.len),
+ @ptrCast(&requested_buffer),
+ );
+ if (selected == c.GO_HANDHELD_UI_PICK_CHANGE_PROVIDER) return .change_provider;
+ if (selected == c.GO_HANDHELD_UI_PICK_SIGN_OUT) return .sign_out;
+ if (selected == c.GO_HANDHELD_UI_PICK_CANCELLED) return .cancelled;
+ if (selected < 0 or selected >= titles.len) return error.InvalidSelection;
+ const title = &titles[@intCast(selected)];
+ std.debug.print("Selected title: {s} ({s})\n", .{
+ displayName(title),
+ catalog.cString(&title.title_id),
+ });
+ return .{ .title = .{
+ .id = catalog.cString(&title.title_id),
+ .name = displayName(title),
+ } };
+ }
+
+ fn fetchVpcId(self: *Service, bearer: []const u8, streaming_url: []const u8) ![]u8 {
+ var url_buffer: [512]u8 = undefined;
+ const url = try std.fmt.bufPrintZ(
+ &url_buffer,
+ "{s}v2/serverInfo",
+ .{streaming_url},
+ );
+ const authorization = try authorizationHeader(self.allocator, bearer);
+ defer secureFree(self.allocator, authorization);
+ var client_id_header: [512]u8 = undefined;
+ var headers = [_][*c]const u8{
+ authorization.ptr,
+ try header(&client_id_header, "nv-client-id", null, self.auth.protocolClientId()),
+ "Accept: application/json",
+ "nv-client-type: NATIVE",
+ "nv-client-streamer: NVIDIA-CLASSIC",
+ "nv-client-version: 2.0.86.124",
+ "nv-device-os: WINDOWS",
+ "nv-device-type: DESKTOP",
+ "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36",
+ };
+ const response = c.go_http_request_bounded_cancelable(
+ "GET",
+ url.ptr,
+ null,
+ @ptrCast(&headers),
+ headers.len,
+ 1024 * 1024,
+ cancelRequest,
+ self.ui,
+ );
+ defer c.go_http_response_destroy(response);
+ if (!successful(response)) {
+ if (c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled;
+ return error.ServerInfoRequestFailed;
+ }
+ return cloudmatch.parseVpcId(self.allocator, response.*.data[0..response.*.len]);
+ }
+
+ fn catalogRequest(self: *Service, bearer: []const u8, body: []const u8) ![*c]c.GoHttpResponse {
+ const terminated = try self.allocator.dupeZ(u8, body);
+ defer self.allocator.free(terminated);
+ const authorization = try authorizationHeader(self.allocator, bearer);
+ defer secureFree(self.allocator, authorization);
+ var client_id_header: [512]u8 = undefined;
+ var headers = [_][*c]const u8{
+ authorization.ptr,
+ try header(&client_id_header, "nv-client-id", null, self.auth.protocolClientId()),
+ "Accept: application/json, text/plain, */*",
+ "Content-Type: application/json",
+ "Origin: https://play.geforcenow.com",
+ "Referer: https://play.geforcenow.com/",
+ "nv-browser-type: CHROME",
+ "nv-client-type: NATIVE",
+ "nv-client-streamer: NVIDIA-CLASSIC",
+ "nv-client-version: 2.0.86.124",
+ "nv-device-make: UNKNOWN",
+ "nv-device-model: UNKNOWN",
+ "nv-device-os: WINDOWS",
+ "nv-device-type: DESKTOP",
+ "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36",
+ };
+ const response = c.go_http_request_bounded_cancelable(
+ "POST",
+ protocol.endpoint,
+ terminated.ptr,
+ @ptrCast(&headers),
+ headers.len,
+ 8 * 1024 * 1024,
+ cancelRequest,
+ self.ui,
+ );
+ if (!successful(response)) {
+ defer c.go_http_response_destroy(response);
+ if (c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled;
+ if (response != null) {
+ std.debug.print("GeForce NOW catalog request returned HTTP {d}\n", .{response.*.status});
+ if (response.*.data != null) {
+ var summary_buffer: [256]u8 = undefined;
+ if (protocol.writeErrorSummary(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ &summary_buffer,
+ )) |summary| std.debug.print("GeForce NOW catalog error: {s}\n", .{summary});
+ }
+ }
+ return error.CatalogRequestFailed;
+ }
+ return response;
+ }
+};
+
+fn cancelRequest(context: ?*anyopaque) callconv(.c) c_int {
+ return c.go_handheld_ui_cancel_requested(@ptrCast(@alignCast(context)));
+}
+
+fn authorizationHeader(allocator: std.mem.Allocator, bearer: []const u8) ![:0]u8 {
+ if (bearer.len == 0 or bearer.len > maximum_bearer_length)
+ return error.InvalidBearerToken;
+ return std.fmt.allocPrintZ(allocator, "Authorization: GFNJWT {s}", .{bearer});
+}
+
+fn secureFree(allocator: std.mem.Allocator, value: [:0]u8) void {
+ std.crypto.secureZero(u8, value);
+ allocator.free(value);
+}
+
+fn header(
+ output: []u8,
+ name: []const u8,
+ scheme: ?[]const u8,
+ value: []const u8,
+) ![*c]const u8 {
+ const result = if (scheme) |prefix|
+ try std.fmt.bufPrintZ(output, "{s}: {s} {s}", .{ name, prefix, value })
+ else
+ try std.fmt.bufPrintZ(output, "{s}: {s}", .{ name, value });
+ return result.ptr;
+}
+
+fn successful(response: [*c]c.GoHttpResponse) bool {
+ return c.go_http_response_succeeded(response) != 0 and response.*.data != null;
+}
+
+fn displayName(title: *const catalog.Title) []const u8 {
+ const name = catalog.cString(&title.name);
+ return if (name.len > 0) name else catalog.cString(&title.title_id);
+}
+
+fn titleLessThan(_: void, left: catalog.Title, right: catalog.Title) bool {
+ return std.ascii.lessThanIgnoreCase(displayName(&left), displayName(&right));
+}
+
+fn titleIdLessThan(_: void, left: catalog.Title, right: catalog.Title) bool {
+ return std.mem.lessThan(
+ u8,
+ catalog.cString(&left.title_id),
+ catalog.cString(&right.title_id),
+ );
+}
+
+fn removeDuplicateLaunchIds(titles: *std.ArrayList(catalog.Title)) void {
+ var write_index: usize = 0;
+ for (titles.items) |title| {
+ const duplicate = write_index > 0 and std.mem.eql(
+ u8,
+ catalog.cString(&titles.items[write_index - 1].title_id),
+ catalog.cString(&title.title_id),
+ );
+ if (!duplicate) {
+ titles.items[write_index] = title;
+ write_index += 1;
+ }
+ }
+ titles.shrinkRetainingCapacity(write_index);
+}
+
+test "deduplicates launch ids after sorting" {
+ var titles = std.ArrayList(catalog.Title).init(std.testing.allocator);
+ defer titles.deinit();
+ for ([_][]const u8{ "two", "one", "one" }) |id| {
+ var title = std.mem.zeroes(catalog.Title);
+ _ = catalog.writeCString(&title.title_id, id);
+ _ = catalog.writeCString(&title.name, id);
+ try titles.append(title);
+ }
+ std.mem.sort(catalog.Title, titles.items, {}, titleIdLessThan);
+ removeDuplicateLaunchIds(&titles);
+ try std.testing.expectEqual(@as(usize, 2), titles.items.len);
+}
+
+test "authorization header accepts realistic token lengths" {
+ const bearer = try std.testing.allocator.alloc(u8, 2048);
+ defer std.testing.allocator.free(bearer);
+ @memset(bearer, 'a');
+ const value = try authorizationHeader(std.testing.allocator, bearer);
+ defer secureFree(std.testing.allocator, value);
+ try std.testing.expectEqual(@as(usize, 2070), value.len);
+ try std.testing.expect(std.mem.endsWith(u8, value, bearer));
+}
+
+test "catalog requests preserve cancellation during HTTP transfers" {
+ const http = @import("gfn_http_fake");
+ var auth = http.authClient(auth_client.Client);
+ var service = Service{
+ .allocator = std.testing.allocator,
+ .auth = &auth,
+ .ui = @ptrCast(auth.ui),
+ };
+ http.reset();
+ try std.testing.expectError(error.Cancelled, service.fetchVpcId("test", "https://example.invalid/"));
+ try std.testing.expectEqual(@as(usize, 1), http.requests);
+ try std.testing.expectEqual(@as(usize, 1), http.polls);
+
+ http.reset();
+ try std.testing.expectError(error.Cancelled, service.catalogRequest("test", "{}"));
+ try std.testing.expectEqual(@as(usize, 1), http.requests);
+ try std.testing.expectEqual(@as(usize, 1), http.polls);
+}
diff --git a/src/provider/geforce_now/cloudmatch_protocol.zig b/src/provider/geforce_now/cloudmatch_protocol.zig
new file mode 100644
index 0000000..5f746ae
--- /dev/null
+++ b/src/provider/geforce_now/cloudmatch_protocol.zig
@@ -0,0 +1,841 @@
+const std = @import("std");
+const endpoint = @import("endpoint.zig");
+const ad_protocol = @import("ad_protocol.zig");
+
+pub const IceServer = struct {
+ urls: [][]u8,
+ username: ?[]u8,
+ credential: ?[]u8,
+
+ fn deinit(self: *IceServer, allocator: std.mem.Allocator) void {
+ for (self.urls) |value| allocator.free(value);
+ allocator.free(self.urls);
+ if (self.username) |value| allocator.free(value);
+ if (self.credential) |value| allocator.free(value);
+ }
+};
+
+pub const Session = struct {
+ allocator: std.mem.Allocator,
+ id: []u8,
+ status: u32,
+ queue_position: ?u32,
+ setup_step: ?u32,
+ signaling_url: ?[]u8,
+ media_ip: ?[]u8,
+ media_port: ?u16,
+ ice_servers: []IceServer,
+ ads: ?ad_protocol.Admission = null,
+
+ pub fn ready(self: *const Session) bool {
+ return self.status == 2 or self.status == 3;
+ }
+
+ pub fn ended(self: *const Session) bool {
+ // Status 6 is cleanup before provisioning can resume.
+ return self.status > 3 and self.status != 6;
+ }
+
+ pub fn deinit(self: *Session) void {
+ if (self.ads) |*ads| ads.deinit();
+ self.allocator.free(self.id);
+ if (self.signaling_url) |value| self.allocator.free(value);
+ if (self.media_ip) |value| self.allocator.free(value);
+ for (self.ice_servers) |*server| server.deinit(self.allocator);
+ self.allocator.free(self.ice_servers);
+ self.* = undefined;
+ }
+};
+
+pub const ActiveSessionSummary = struct {
+ active: usize = 0,
+ same_device: usize = 0,
+ matching_app: usize = 0,
+};
+
+pub fn parseVpcId(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const root = try object(parsed.value);
+ const status = try object(root.get("requestStatus") orelse return error.MissingStatus);
+ if ((optionalUnsigned(status, "statusCode") orelse 0) != 1) return error.RequestRejected;
+ const server_id = try requiredString(status, "serverId");
+ return allocator.dupe(u8, server_id);
+}
+
+pub fn parseLocalRegionUrl(allocator: std.mem.Allocator, data: []const u8) !?[]u8 {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const root = try object(parsed.value);
+ const metadata = array(root.get("metaData") orelse return null) catch return null;
+ var local_region: ?[]const u8 = null;
+ for (metadata.items) |entry_value| {
+ const entry = object(entry_value) catch continue;
+ const key = (optionalString(entry, "key") catch continue) orelse continue;
+ if (!std.mem.eql(u8, key, "local-region")) continue;
+ local_region = (optionalString(entry, "value") catch continue) orelse continue;
+ break;
+ }
+ const region = local_region orelse return null;
+ for (metadata.items) |entry_value| {
+ const entry = object(entry_value) catch continue;
+ const key = (optionalString(entry, "key") catch continue) orelse continue;
+ if (!std.mem.eql(u8, key, region)) continue;
+ const url = (optionalString(entry, "value") catch continue) orelse continue;
+ return try endpoint.normalizeCloudMatchBase(allocator, url);
+ }
+ return null;
+}
+
+pub fn buildSessionRequest(
+ allocator: std.mem.Allocator,
+ app_id: []const u8,
+ internal_title: []const u8,
+ device_id: []const u8,
+ width: u16,
+ height: u16,
+ frames_per_second: u16,
+ user_age: u8,
+) ![]u8 {
+ if (app_id.len == 0 or device_id.len == 0 or
+ width < 320 or height < 240 or frames_per_second == 0)
+ return error.InvalidSessionRequest;
+ const numeric_app_id = std.fmt.parseUnsigned(u64, app_id, 10) catch
+ return error.InvalidSessionRequest;
+
+ const title: ?[]const u8 = if (internal_title.len > 0) internal_title else null;
+
+ return std.json.stringifyAlloc(allocator, .{
+ .sessionRequestData = .{
+ .appId = numeric_app_id,
+ .cmsId = app_id,
+ .internalTitle = title,
+ .availableSupportedControllers = &[_]struct {}{},
+ .networkTestSessionId = null,
+ .parentSessionId = null,
+ .clientIdentification = "GFN-PC",
+ .deviceHashId = device_id,
+ .clientVersion = "30.0",
+ .sdkVersion = "1.0",
+ .streamerVersion = 1,
+ .clientPlatformName = "windows",
+ .clientRequestMonitorSettings = &.{.{
+ .monitorId = 0,
+ .positionX = 0,
+ .positionY = 0,
+ .widthInPixels = width,
+ .heightInPixels = height,
+ .framesPerSecond = frames_per_second,
+ .sdrHdrMode = 0,
+ .displayData = null,
+ .hdr10PlusGamingData = null,
+ .dpi = 100,
+ }},
+ .useOps = true,
+ .audioMode = 2,
+ .metaData = &.{
+ .{ .key = "SubSessionId", .value = device_id },
+ .{ .key = "wssignaling", .value = "1" },
+ .{ .key = "GSStreamerType", .value = "WebRTC" },
+ },
+ .sdrHdrMode = 0,
+ .clientDisplayHdrCapabilities = null,
+ .surroundAudioInfo = 0,
+ .remoteControllersBitmap = 1,
+ .clientTimezoneOffset = 0,
+ .enhancedStreamMode = 1,
+ .appLaunchMode = 1,
+ .secureRTSPSupported = false,
+ .partnerCustomData = "",
+ // The catalog only exposes owned library variants.
+ .accountLinked = true,
+ .enablePersistingInGameSettings = false,
+ .userAge = user_age,
+ .requestedStreamingFeatures = .{
+ .reflex = false,
+ .bitDepth = 0,
+ .cloudGsync = false,
+ .enabledL4S = false,
+ .mouseMovementFlags = 0,
+ .trueHdr = false,
+ .supportedHidDevices = 0,
+ .profile = 0,
+ .fallbackToLogicalResolution = false,
+ .hidDevices = null,
+ .chromaFormat = 0,
+ .prefilterMode = 0,
+ .prefilterSharpness = 0,
+ .prefilterNoiseReduction = 0,
+ .hudStreamingMode = 0,
+ .sdrColorSpace = 2,
+ .hdrColorSpace = 0,
+ },
+ },
+ }, .{ .emit_null_optional_fields = true });
+}
+
+pub fn writeErrorSummary(
+ allocator: std.mem.Allocator,
+ data: []const u8,
+ output: []u8,
+) ?[]const u8 {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return null;
+ defer parsed.deinit();
+ const root = object(parsed.value) catch return null;
+ const request_status = object(root.get("requestStatus") orelse return null) catch return null;
+ const description = (optionalString(request_status, "statusDescription") catch return null) orelse
+ return null;
+ const status_code = optionalUnsigned(request_status, "statusCode");
+ const unified_code = optionalUnsigned(request_status, "unifiedErrorCode");
+ const session_code = nestedUnsigned(root, "session.errorCode");
+
+ var sanitized: [256]u8 = undefined;
+ const description_length = @min(description.len, sanitized.len);
+ for (description[0..description_length], 0..) |byte, index| {
+ sanitized[index] = if (byte >= 0x20 and byte != 0x7f) byte else ' ';
+ }
+
+ return if (session_code) |session_error|
+ std.fmt.bufPrint(output, "{s} (status {d}, code {d}, session code {d})", .{
+ sanitized[0..description_length],
+ status_code orelse 0,
+ unified_code orelse 0,
+ session_error,
+ }) catch null
+ else if (unified_code) |code|
+ std.fmt.bufPrint(output, "{s} (status {d}, code {d})", .{
+ sanitized[0..description_length],
+ status_code orelse 0,
+ code,
+ }) catch null
+ else if (status_code) |code|
+ std.fmt.bufPrint(output, "{s} (status {d})", .{
+ sanitized[0..description_length],
+ code,
+ }) catch null
+ else
+ std.fmt.bufPrint(output, "{s}", .{sanitized[0..description_length]}) catch null;
+}
+
+pub fn sessionFailureIsRetryable(allocator: std.mem.Allocator, data: []const u8) ?bool {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
+ return null;
+ defer parsed.deinit();
+ const root = object(parsed.value) catch return null;
+ const request_status = object(root.get("requestStatus") orelse return null) catch return null;
+ const status_code = optionalUnsigned(request_status, "statusCode") orelse return null;
+ if (status_code == 1 or status_code >= 255) return false;
+
+ const server_error_base: usize = 3_237_093_632;
+ var code = server_error_base + status_code;
+ if (status_code == 0 or status_code == 4) {
+ if (optionalUnsigned(request_status, "unifiedErrorCode")) |unified| code = unified;
+ }
+
+ return switch (code) {
+ 3_237_089_282,
+ 3_237_093_635,
+ 3_237_093_636,
+ 3_237_093_683,
+ 3_237_093_690,
+ 3_237_093_717,
+ 3_237_101_584,
+ 3_237_101_585,
+ 3_237_101_586,
+ => true,
+ else => false,
+ };
+}
+
+pub fn parseActiveSessionSummary(
+ allocator: std.mem.Allocator,
+ data: []const u8,
+ device_id: []const u8,
+ app_id: []const u8,
+) !ActiveSessionSummary {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const root = try object(parsed.value);
+ const request_status = try object(root.get("requestStatus") orelse return error.MissingStatus);
+ if ((optionalUnsigned(request_status, "statusCode") orelse 0) != 1)
+ return error.RequestRejected;
+ const sessions = try array(root.get("sessions") orelse return error.MissingSessions);
+
+ var summary = ActiveSessionSummary{};
+ for (sessions.items) |session_value| {
+ const session = object(session_value) catch continue;
+ const status = optionalUnsigned(session, "status") orelse continue;
+ if (status < 1 or status > 3) continue;
+ summary.active += 1;
+ const request_data = object(session.get("sessionRequestData") orelse continue) catch continue;
+ if (try identifierEquals(request_data.get("appId"), app_id))
+ summary.matching_app += 1;
+ if (try identifierEquals(request_data.get("deviceHashId"), device_id))
+ summary.same_device += 1;
+ }
+ return summary;
+}
+
+pub fn parseSession(allocator: std.mem.Allocator, data: []const u8) !Session {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const root = try object(parsed.value);
+ const request_status = try object(root.get("requestStatus") orelse return error.MissingStatus);
+ if ((optionalUnsigned(request_status, "statusCode") orelse 0) != 1)
+ return error.RequestRejected;
+ const session = try object(root.get("session") orelse return error.MissingSession);
+ const id = try duplicateIdentifier(allocator, session.get("sessionId") orelse return error.MissingSessionId);
+ errdefer allocator.free(id);
+
+ const status = sessionStatus(session.get("status")) orelse return error.InvalidSessionStatus;
+ const queue_position = nestedUnsigned(session, "queuePosition") orelse
+ nestedUnsigned(session, "seatSetupInfo.queuePosition") orelse
+ nestedUnsigned(session, "sessionProgress.queuePosition") orelse
+ nestedUnsigned(session, "progressInfo.queuePosition") orelse
+ nestedUnsigned(root, "queuePosition");
+ const setup_step = nestedUnsigned(session, "seatSetupStep") orelse
+ nestedUnsigned(session, "seatSetupInfo.seatSetupStep");
+
+ var signaling_ip: ?[]u8 = null;
+ var signaling_path: ?[]u8 = null;
+ var media_ip: ?[]u8 = null;
+ var media_port: ?u16 = null;
+ errdefer if (signaling_ip) |value| allocator.free(value);
+ errdefer if (signaling_path) |value| allocator.free(value);
+ errdefer if (media_ip) |value| allocator.free(value);
+ if (session.get("connectionInfo")) |connections_value| {
+ const connections = switch (connections_value) {
+ .array => |values| values,
+ else => null,
+ };
+ if (connections) |values| for (values.items) |connection_value| {
+ const connection = object(connection_value) catch continue;
+ const usage = optionalUnsigned(connection, "usage") orelse continue;
+ const ip = if (connection.get("ip")) |value| duplicateIp(allocator, value) catch |err| switch (err) {
+ error.OutOfMemory => return err,
+ else => null,
+ } else null;
+ errdefer if (ip) |value| allocator.free(value);
+ const port_value = optionalUnsigned(connection, "port");
+ const port: ?u16 = if (port_value != null and port_value.? <= std.math.maxInt(u16)) @intCast(port_value.?) else null;
+ const path = duplicateOptionalString(allocator, connection, "resourcePath") catch |err| switch (err) {
+ error.OutOfMemory => return err,
+ else => null,
+ };
+ errdefer if (path) |value| allocator.free(value);
+ if (usage == 14 and signaling_ip == null) {
+ if (signaling_path) |value| allocator.free(value);
+ signaling_ip = ip;
+ signaling_path = path;
+ } else if ((usage == 2 or usage == 17) and media_ip == null and ip != null and port != null) {
+ media_ip = ip;
+ media_port = port;
+ if (path) |value| allocator.free(value);
+ } else {
+ if (ip) |value| allocator.free(value);
+ if (path) |value| allocator.free(value);
+ }
+ };
+ }
+ if (signaling_ip == null) {
+ if (session.get("sessionControlInfo")) |control_value| {
+ const control = object(control_value) catch null;
+ if (control) |value| {
+ if (value.get("ip")) |ip| signaling_ip = duplicateIp(allocator, ip) catch |err| switch (err) {
+ error.OutOfMemory => return err,
+ else => null,
+ };
+ }
+ }
+ }
+ const signaling_url = if (signaling_ip) |ip|
+ try buildSignalingUrl(allocator, ip, signaling_path)
+ else
+ null;
+ errdefer if (signaling_url) |value| allocator.free(value);
+ if (signaling_ip) |value| allocator.free(value);
+ if (signaling_path) |value| allocator.free(value);
+ signaling_ip = null;
+ signaling_path = null;
+
+ const ice_servers = try parseIceServers(allocator, session);
+ errdefer {
+ for (ice_servers) |*server| server.deinit(allocator);
+ allocator.free(ice_servers);
+ }
+
+ return .{
+ .allocator = allocator,
+ .id = id,
+ .status = status,
+ .queue_position = boundedU32(queue_position),
+ .setup_step = boundedU32(setup_step),
+ .signaling_url = signaling_url,
+ .media_ip = media_ip,
+ .media_port = media_port,
+ .ice_servers = ice_servers,
+ .ads = try ad_protocol.parse(allocator, session),
+ };
+}
+
+fn boundedU32(value: ?u64) ?u32 {
+ const number = value orelse return null;
+ if (number > std.math.maxInt(u32)) return null;
+ return @intCast(number);
+}
+
+fn buildSignalingUrl(
+ allocator: std.mem.Allocator,
+ ip: []const u8,
+ path: ?[]const u8,
+) ![]u8 {
+ const resource = path orelse "/nvst/";
+ if (std.mem.startsWith(u8, resource, "wss://")) return allocator.dupe(u8, resource);
+ if (std.mem.startsWith(u8, resource, "rtsps://") or
+ std.mem.startsWith(u8, resource, "rtsp://"))
+ {
+ const delimiter = std.mem.indexOf(u8, resource, "://").? + 3;
+ const end = std.mem.indexOfScalarPos(u8, resource, delimiter, '/') orelse resource.len;
+ const host = resource[delimiter..end];
+ return std.fmt.allocPrint(allocator, "wss://{s}/nvst/", .{host});
+ }
+ if (std.mem.startsWith(u8, resource, "/"))
+ return std.fmt.allocPrint(allocator, "wss://{s}:443{s}", .{ ip, resource });
+ return std.fmt.allocPrint(allocator, "wss://{s}:443/nvst/", .{ip});
+}
+
+fn parseIceServers(allocator: std.mem.Allocator, session: std.json.ObjectMap) ![]IceServer {
+ const configuration_value = session.get("iceServerConfiguration") orelse
+ return allocator.alloc(IceServer, 0);
+ const configuration = object(configuration_value) catch return allocator.alloc(IceServer, 0);
+ const servers_value = configuration.get("iceServers") orelse
+ return allocator.alloc(IceServer, 0);
+ const values = array(servers_value) catch return allocator.alloc(IceServer, 0);
+ var result = std.ArrayList(IceServer).init(allocator);
+ errdefer {
+ for (result.items) |*server| server.deinit(allocator);
+ result.deinit();
+ }
+ for (values.items) |server_value| {
+ const server = object(server_value) catch continue;
+ var urls = std.ArrayList([]u8).init(allocator);
+ errdefer {
+ for (urls.items) |url| allocator.free(url);
+ urls.deinit();
+ }
+ if (server.get("urls")) |urls_value| switch (urls_value) {
+ .string => |url| try appendIceUrl(allocator, &urls, url),
+ .array => |array_value| for (array_value.items) |url_value| switch (url_value) {
+ .string => |url| try appendIceUrl(allocator, &urls, url),
+ else => {},
+ },
+ else => {},
+ };
+ if (urls.items.len == 0) {
+ urls.deinit();
+ continue;
+ }
+ const owned_urls = try urls.toOwnedSlice();
+ errdefer {
+ for (owned_urls) |url| allocator.free(url);
+ allocator.free(owned_urls);
+ }
+ const username = try duplicateOptionalString(allocator, server, "username");
+ errdefer if (username) |value| allocator.free(value);
+ const credential = try duplicateOptionalString(allocator, server, "credential");
+ errdefer if (credential) |value| allocator.free(value);
+ try result.append(.{
+ .urls = owned_urls,
+ .username = username,
+ .credential = credential,
+ });
+ }
+ return result.toOwnedSlice();
+}
+
+fn appendIceUrl(allocator: std.mem.Allocator, urls: *std.ArrayList([]u8), url: []const u8) !void {
+ if (url.len == 0) return;
+ const owned = try allocator.dupe(u8, url);
+ errdefer allocator.free(owned);
+ try urls.append(owned);
+}
+
+fn nestedUnsigned(root: std.json.ObjectMap, path: []const u8) ?usize {
+ var fields = std.mem.splitScalar(u8, path, '.');
+ var current = root;
+ while (fields.next()) |field| {
+ const value = current.get(field) orelse return null;
+ if (fields.peek() == null) return switch (value) {
+ .integer => |number| if (number >= 0 and number <= std.math.maxInt(usize)) @intCast(number) else null,
+ else => null,
+ };
+ current = object(value) catch return null;
+ }
+ return null;
+}
+
+fn sessionStatus(value: ?std.json.Value) ?u32 {
+ const actual = value orelse return null;
+ return switch (actual) {
+ .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32)) @intCast(number) else null,
+ .string => |text| if (std.ascii.eqlIgnoreCase(text, "queued"))
+ 0
+ else if (std.ascii.eqlIgnoreCase(text, "provisioning") or
+ std.ascii.eqlIgnoreCase(text, "initializing") or
+ std.ascii.eqlIgnoreCase(text, "setup") or
+ std.ascii.eqlIgnoreCase(text, "setting_up") or
+ std.ascii.eqlIgnoreCase(text, "launching") or
+ std.ascii.eqlIgnoreCase(text, "launching_game"))
+ 1
+ else if (std.ascii.eqlIgnoreCase(text, "active") or
+ std.ascii.eqlIgnoreCase(text, "ready") or
+ std.ascii.eqlIgnoreCase(text, "paused"))
+ 2
+ else if (std.ascii.eqlIgnoreCase(text, "streaming") or
+ std.ascii.eqlIgnoreCase(text, "playing") or
+ std.ascii.eqlIgnoreCase(text, "connected"))
+ 3
+ else
+ null,
+ else => null,
+ };
+}
+
+fn duplicateIdentifier(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 {
+ return switch (value) {
+ .string => |text| if (text.len > 0) allocator.dupe(u8, text) else error.InvalidIdentifier,
+ .integer => |number| if (number >= 0)
+ std.fmt.allocPrint(allocator, "{d}", .{number})
+ else
+ error.InvalidIdentifier,
+ else => error.InvalidIdentifier,
+ };
+}
+
+fn identifierEquals(value: ?std.json.Value, expected: []const u8) !bool {
+ const actual = value orelse return false;
+ return switch (actual) {
+ .string => |text| std.mem.eql(u8, text, expected),
+ .integer => |number| if (number >= 0) result: {
+ var buffer: [32]u8 = undefined;
+ const text = try std.fmt.bufPrint(&buffer, "{d}", .{number});
+ break :result std.mem.eql(u8, text, expected);
+ } else false,
+ else => false,
+ };
+}
+
+fn duplicateIp(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 {
+ return switch (value) {
+ .string => |text| if (text.len > 0) allocator.dupe(u8, text) else error.InvalidIp,
+ .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32)) blk: {
+ const ip: u32 = @intCast(number);
+ break :blk std.fmt.allocPrint(allocator, "{d}.{d}.{d}.{d}", .{
+ ip >> 24,
+ (ip >> 16) & 0xff,
+ (ip >> 8) & 0xff,
+ ip & 0xff,
+ });
+ } else error.InvalidIp,
+ .array => |values| if (values.items.len > 0) duplicateIp(allocator, values.items[0]) else error.InvalidIp,
+ .object => |fields| if (fields.get("value")) |nested| duplicateIp(allocator, nested) else error.InvalidIp,
+ else => error.InvalidIp,
+ };
+}
+
+fn duplicateOptionalString(
+ allocator: std.mem.Allocator,
+ value: std.json.ObjectMap,
+ key: []const u8,
+) !?[]u8 {
+ if (try optionalString(value, key)) |text| return try allocator.dupe(u8, text);
+ return null;
+}
+
+fn object(value: std.json.Value) !std.json.ObjectMap {
+ return switch (value) {
+ .object => |result| result,
+ else => error.ExpectedObject,
+ };
+}
+
+fn array(value: std.json.Value) !std.json.Array {
+ return switch (value) {
+ .array => |result| result,
+ else => error.ExpectedArray,
+ };
+}
+
+fn requiredString(value: std.json.ObjectMap, key: []const u8) ![]const u8 {
+ return (try optionalString(value, key)) orelse error.MissingField;
+}
+
+fn optionalString(value: std.json.ObjectMap, key: []const u8) !?[]const u8 {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .string => |text| if (text.len > 0) text else null,
+ .null => null,
+ else => error.InvalidField,
+ };
+}
+
+fn optionalUnsigned(value: std.json.ObjectMap, key: []const u8) ?usize {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .integer => |number| if (number >= 0 and number <= std.math.maxInt(usize)) @intCast(number) else null,
+ else => null,
+ };
+}
+
+test "parses VPC id from server info" {
+ const id = try parseVpcId(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":1,"serverId":"GFN-PC"}}
+ );
+ defer std.testing.allocator.free(id);
+ try std.testing.expectEqualStrings("GFN-PC", id);
+}
+
+test "queue and cleanup states remain pending while terminal states end the wait" {
+ for ([_]u32{ 0, 1, 2, 3, 4, 5, 6, 7 }) |status| {
+ const data = try std.fmt.allocPrint(std.testing.allocator,
+ \\{{"requestStatus":{{"statusCode":1}},"session":{{"sessionId":"test","status":{d}}}}}
+ , .{status});
+ defer std.testing.allocator.free(data);
+ var session = try parseSession(std.testing.allocator, data);
+ defer session.deinit();
+ try std.testing.expectEqual(status == 2 or status == 3, session.ready());
+ try std.testing.expectEqual(status == 4 or status == 5 or status == 7, session.ended());
+ }
+}
+
+test "invalid session states are not treated as a queue" {
+ for ([_][]const u8{ "null", "-1", "4294967296", "\"unrecognized\"" }) |status| {
+ const data = try std.fmt.allocPrint(std.testing.allocator,
+ \\{{"requestStatus":{{"statusCode":1}},"session":{{"sessionId":"test","status":{s}}}}}
+ , .{status});
+ defer std.testing.allocator.free(data);
+ try std.testing.expectError(error.InvalidSessionStatus, parseSession(std.testing.allocator, data));
+ }
+}
+
+test "parses and validates the advertised local CloudMatch region" {
+ const url = (try parseLocalRegionUrl(std.testing.allocator,
+ \\{"metaData":[
+ \\ {"key":"local-region","value":"np-ams-06"},
+ \\ {"key":"np-ams-06","value":"https://np-ams-06.cloudmatchbeta.nvidiagrid.net"}
+ \\]}
+ )).?;
+ defer std.testing.allocator.free(url);
+ try std.testing.expectEqualStrings("https://np-ams-06.cloudmatchbeta.nvidiagrid.net/", url);
+
+ try std.testing.expectError(
+ error.UntrustedCloudMatchUrl,
+ parseLocalRegionUrl(std.testing.allocator,
+ \\{"metaData":[
+ \\ {"key":"local-region","value":"np-ams-06"},
+ \\ {"key":"np-ams-06","value":"https://example.invalid"}
+ \\]}
+ ),
+ );
+}
+
+test "summarizes active sessions without retaining identifiers" {
+ const summary = try parseActiveSessionSummary(
+ std.testing.allocator,
+ \\{"requestStatus":{"statusCode":1},"sessions":[
+ \\ {"status":3,"sessionRequestData":{"appId":1001,"deviceHashId":"device-a"}},
+ \\ {"status":1,"sessionRequestData":{"appId":"2002","deviceHashId":"device-b"}},
+ \\ {"status":4,"sessionRequestData":{"appId":"1001","deviceHashId":"device-a"}}
+ \\]}
+ ,
+ "device-a",
+ "1001",
+ );
+ try std.testing.expectEqual(@as(usize, 2), summary.active);
+ try std.testing.expectEqual(@as(usize, 1), summary.same_device);
+ try std.testing.expectEqual(@as(usize, 1), summary.matching_app);
+}
+
+test "builds a display-matched WebRTC session request" {
+ const body = try buildSessionRequest(
+ std.testing.allocator,
+ "202",
+ "Test Game",
+ "device-one",
+ 640,
+ 480,
+ 30,
+ 37,
+ );
+ defer std.testing.allocator.free(body);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"widthInPixels\":640") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"heightInPixels\":480") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "GSStreamerType") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"appId\":202") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"cmsId\":\"202\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"internalTitle\":\"Test Game\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"availableSupportedControllers\":[]") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"displayData\":null") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"dpi\":100") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"remoteControllersBitmap\":1") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"prefilterMode\":0") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"sdrColorSpace\":2") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"maxBitrateKbps\"") == null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"codec\"") == null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"userAge\":37") != null);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\"accountLinked\":true") != null);
+}
+
+test "writes a bounded CloudMatch error summary" {
+ var output: [128]u8 = undefined;
+ const summary = writeErrorSummary(
+ std.testing.allocator,
+ \\{"requestStatus":{"statusCode":0,"statusDescription":"Unsupported request\n","unifiedErrorCode":42}}
+ ,
+ &output,
+ ) orelse return error.MissingSummary;
+ try std.testing.expectEqualStrings("Unsupported request (status 0, code 42)", summary);
+}
+
+test "includes a session error code in the CloudMatch summary" {
+ var output: [160]u8 = undefined;
+ const summary = writeErrorSummary(
+ std.testing.allocator,
+ \\{"requestStatus":{"statusCode":4,"statusDescription":"INTERNAL_ERROR_STATUS","unifiedErrorCode":0},"session":{"errorCode":2324439040}}
+ ,
+ &output,
+ ) orelse return error.MissingSummary;
+ try std.testing.expectEqualStrings(
+ "INTERNAL_ERROR_STATUS (status 4, code 0, session code 2324439040)",
+ summary,
+ );
+}
+
+test "classifies CloudMatch session failures before retrying" {
+ try std.testing.expectEqual(
+ false,
+ sessionFailureIsRetryable(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":81,"statusDescription":"STREAMING_NOT_ALLOWED_IN_LIMITED_MODE"}}
+ ).?,
+ );
+ try std.testing.expectEqual(
+ true,
+ sessionFailureIsRetryable(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":58,"statusDescription":"INSUFFICIENT_VM_CAPACITY"}}
+ ).?,
+ );
+ try std.testing.expectEqual(
+ false,
+ sessionFailureIsRetryable(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":4,"unifiedErrorCode":3237093713}}
+ ).?,
+ );
+ try std.testing.expect(sessionFailureIsRetryable(std.testing.allocator, "not json") == null);
+}
+
+test "parses queue and connection state" {
+ var session = try parseSession(std.testing.allocator,
+ \\{
+ \\ "requestStatus":{"statusCode":1},
+ \\ "session":{
+ \\ "sessionId":"session-one","status":2,
+ \\ "seatSetupInfo":{"queuePosition":4,"seatSetupStep":1},
+ \\ "connectionInfo":[
+ \\ {"usage":14,"ip":{"value":"203.0.113.10"},"port":443,"resourcePath":"/nvst/"},
+ \\ {"usage":2,"ip":3405803787,"port":49005}
+ \\ ],
+ \\ "iceServerConfiguration":{"iceServers":[{"urls":"stun:example.invalid:3478","username":"user","credential":"secret"}]}
+ \\ }
+ \\}
+ );
+ defer session.deinit();
+
+ try std.testing.expect(session.ready());
+ try std.testing.expectEqual(@as(?u32, 4), session.queue_position);
+ try std.testing.expectEqual(@as(?u32, 1), session.setup_step);
+ try std.testing.expectEqualStrings("wss://203.0.113.10:443/nvst/", session.signaling_url.?);
+ try std.testing.expectEqualStrings("203.0.113.11", session.media_ip.?);
+ try std.testing.expectEqual(@as(?u16, 49005), session.media_port);
+ try std.testing.expectEqual(@as(usize, 1), session.ice_servers.len);
+}
+
+test "accepts a queued response before connection details are assigned" {
+ var session = try parseSession(std.testing.allocator,
+ \\{
+ \\ "requestStatus":{"statusCode":1},
+ \\ "queuePosition":42,
+ \\ "session":{"sessionId":"session-one","status":"QUEUED","connectionInfo":null}
+ \\}
+ );
+ defer session.deinit();
+
+ try std.testing.expectEqual(@as(u32, 0), session.status);
+ try std.testing.expectEqual(@as(?u32, 42), session.queue_position);
+ try std.testing.expectEqual(@as(?[]u8, null), session.signaling_url);
+}
+
+test "numeric and structured control addresses preserve signaling and ad admission" {
+ for ([_][]const u8{ "3405803787", "{\"value\":\"203.0.113.11\"}" }) |ip| {
+ for ([_]bool{ false, true }) |required| {
+ const bytes = try std.fmt.allocPrint(
+ std.testing.allocator,
+ "{{\"requestStatus\":{{\"statusCode\":1}},\"session\":{{\"sessionId\":\"one\",\"status\":2,\"sessionAdsRequired\":{},\"sessionControlInfo\":{{\"ip\":{s}}}}}}}",
+ .{ required, ip },
+ );
+ defer std.testing.allocator.free(bytes);
+ var session = try parseSession(std.testing.allocator, bytes);
+ defer session.deinit();
+ try std.testing.expectEqualStrings("wss://203.0.113.11:443/nvst/", session.signaling_url.?);
+ try std.testing.expectEqual(required, session.ads.?.required);
+ try std.testing.expect(session.ads.?.control_base == null);
+ }
+ }
+}
+
+test "ignores queue values outside the supported range" {
+ var session = try parseSession(std.testing.allocator,
+ \\{
+ \\ "requestStatus":{"statusCode":1},
+ \\ "session":{
+ \\ "sessionId":"session-one","status":"QUEUED",
+ \\ "queuePosition":4294967296,"seatSetupStep":4294967296
+ \\ }
+ \\}
+ );
+ defer session.deinit();
+
+ try std.testing.expectEqual(@as(?u32, null), session.queue_position);
+ try std.testing.expectEqual(@as(?u32, null), session.setup_step);
+}
+
+test "session parsing replaces incomplete signaling endpoints without leaking" {
+ var session = try parseSession(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":1},"session":{
+ \\"sessionId":"session-one","status":2,"connectionInfo":[
+ \\ {"usage":14,"resourcePath":"/incomplete/"},
+ \\ {"usage":14,"ip":"203.0.113.10","resourcePath":"/nvst/"}
+ \\]}}
+ );
+ defer session.deinit();
+ try std.testing.expectEqualStrings("wss://203.0.113.10:443/nvst/", session.signaling_url.?);
+}
+
+test "session parsing releases partial allocations" {
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
+ fn run(allocator: std.mem.Allocator) !void {
+ var session = try parseSession(allocator,
+ \\{"requestStatus":{"statusCode":1},"session":{
+ \\"sessionId":"session-one","status":2,
+ \\"connectionInfo":[
+ \\ {"usage":14,"ip":"203.0.113.10","resourcePath":"/nvst/"},
+ \\ {"usage":2,"ip":3405803787,"port":49005}
+ \\],
+ \\"iceServerConfiguration":{"iceServers":[
+ \\ {"urls":"stun:example.invalid:3478"},
+ \\ {"urls":["turn:example.invalid:3478","turns:example.invalid:443"],"username":"user","credential":"test"}
+ \\]}}}
+ );
+ defer session.deinit();
+ }
+ }.run, .{});
+}
diff --git a/src/provider/geforce_now/endpoint.zig b/src/provider/geforce_now/endpoint.zig
new file mode 100644
index 0000000..611a4c2
--- /dev/null
+++ b/src/provider/geforce_now/endpoint.zig
@@ -0,0 +1,65 @@
+const std = @import("std");
+
+pub fn normalizeCloudMatchBase(
+ allocator: std.mem.Allocator,
+ url: []const u8,
+) ![]u8 {
+ const parsed = std.Uri.parse(url) catch return error.UntrustedCloudMatchUrl;
+ if (!std.ascii.eqlIgnoreCase(parsed.scheme, "https") or
+ parsed.user != null or parsed.password != null or
+ (parsed.port != null and parsed.port.? != 443) or
+ parsed.query != null or parsed.fragment != null)
+ return error.UntrustedCloudMatchUrl;
+
+ const host = componentBytes(parsed.host orelse return error.UntrustedCloudMatchUrl) orelse
+ return error.UntrustedCloudMatchUrl;
+ const path = componentBytes(parsed.path) orelse return error.UntrustedCloudMatchUrl;
+ if ((path.len != 0 and !std.mem.eql(u8, path, "/")) or !trustedHost(host))
+ return error.UntrustedCloudMatchUrl;
+
+ return if (std.mem.endsWith(u8, url, "/"))
+ allocator.dupe(u8, url)
+ else
+ std.fmt.allocPrint(allocator, "{s}/", .{url});
+}
+
+fn trustedHost(host: []const u8) bool {
+ const root = "nvidiagrid.net";
+ return std.ascii.eqlIgnoreCase(host, root) or
+ (host.len > root.len and host[host.len - root.len - 1] == '.' and
+ std.ascii.endsWithIgnoreCase(host, root));
+}
+
+fn componentBytes(component: std.Uri.Component) ?[]const u8 {
+ return switch (component) {
+ .raw => |value| value,
+ .percent_encoded => |value| if (std.mem.indexOfScalar(u8, value, '%') == null) value else null,
+ };
+}
+
+test "normalizes trusted CloudMatch endpoints" {
+ const normalized = try normalizeCloudMatchBase(
+ std.testing.allocator,
+ "https://np-ams-06.cloudmatchbeta.nvidiagrid.net",
+ );
+ defer std.testing.allocator.free(normalized);
+ try std.testing.expectEqualStrings(
+ "https://np-ams-06.cloudmatchbeta.nvidiagrid.net/",
+ normalized,
+ );
+}
+
+test "rejects endpoints that could receive an account token" {
+ for ([_][]const u8{
+ "http://prod.cloudmatchbeta.nvidiagrid.net",
+ "https://example.invalid",
+ "https://user@prod.cloudmatchbeta.nvidiagrid.net",
+ "https://prod.cloudmatchbeta.nvidiagrid.net/path",
+ "https://prod.cloudmatchbeta.nvidiagrid.net/?query=1",
+ }) |url| {
+ try std.testing.expectError(
+ error.UntrustedCloudMatchUrl,
+ normalizeCloudMatchBase(std.testing.allocator, url),
+ );
+ }
+}
diff --git a/src/provider/geforce_now/input_protocol.zig b/src/provider/geforce_now/input_protocol.zig
new file mode 100644
index 0000000..9e781f5
--- /dev/null
+++ b/src/provider/geforce_now/input_protocol.zig
@@ -0,0 +1,268 @@
+const std = @import("std");
+
+pub const Button = struct {
+ pub const dpad_up: u16 = 0x0001;
+ pub const dpad_down: u16 = 0x0002;
+ pub const dpad_left: u16 = 0x0004;
+ pub const dpad_right: u16 = 0x0008;
+ pub const start: u16 = 0x0010;
+ pub const back: u16 = 0x0020;
+ pub const left_stick: u16 = 0x0040;
+ pub const right_stick: u16 = 0x0080;
+ pub const left_shoulder: u16 = 0x0100;
+ pub const right_shoulder: u16 = 0x0200;
+ pub const guide: u16 = 0x0400;
+ pub const a: u16 = 0x1000;
+ pub const b: u16 = 0x2000;
+ pub const x: u16 = 0x4000;
+ pub const y: u16 = 0x8000;
+};
+
+pub const GamepadState = struct {
+ controller_id: u8 = 0,
+ gamepad_bitmap: u16 = 0x0101,
+ buttons: u16 = 0,
+ left_trigger: u8 = 0,
+ right_trigger: u8 = 0,
+ left_x: i16 = 0,
+ left_y: i16 = 0,
+ right_x: i16 = 0,
+ right_y: i16 = 0,
+ timestamp_us: u64 = 0,
+};
+
+pub const Encoder = struct {
+ protocol_version: u16 = 2,
+
+ pub fn setProtocolVersion(self: *Encoder, version: u16) void {
+ self.protocol_version = version;
+ }
+
+ pub fn encodeHeartbeat(_: *const Encoder, output: []u8) ![]const u8 {
+ if (output.len < heartbeat_size) return error.NoSpace;
+ std.mem.writeInt(u32, output[0..heartbeat_size], 2, .little);
+ return output[0..heartbeat_size];
+ }
+
+ pub fn encodeMousePosition(self: *const Encoder, output: []u8, x: u16, y: u16, width: u16, height: u16, timestamp_us: u64) ![]const u8 {
+ if (width == 0 or height == 0) return error.InvalidPointerDimensions;
+ const body = try self.mousePacket(output, 26, true, timestamp_us);
+ std.mem.writeInt(u32, body[0..4], 5, .little);
+ std.mem.writeInt(u16, body[4..6], @min(x, width - 1), .big);
+ std.mem.writeInt(u16, body[6..8], @min(y, height - 1), .big);
+ std.mem.writeInt(u16, body[10..12], width, .big);
+ std.mem.writeInt(u16, body[12..14], height, .big);
+ std.mem.writeInt(u64, body[18..26], timestamp_us, .big);
+ return output[0..self.mousePacketSize(26, true)];
+ }
+
+ pub fn encodeMouseButton(self: *const Encoder, output: []u8, button: MouseButton, down: bool, timestamp_us: u64) ![]const u8 {
+ const body = try self.mousePacket(output, 18, false, timestamp_us);
+ std.mem.writeInt(u32, body[0..4], if (down) 8 else 9, .little);
+ body[4] = @intFromEnum(button);
+ std.mem.writeInt(u64, body[10..18], timestamp_us, .big);
+ return output[0..self.mousePacketSize(18, false)];
+ }
+
+ pub fn encodeMouseWheel(self: *const Encoder, output: []u8, delta: i16, timestamp_us: u64) ![]const u8 {
+ const body = try self.mousePacket(output, 22, false, timestamp_us);
+ std.mem.writeInt(u32, body[0..4], 10, .little);
+ std.mem.writeInt(i16, body[6..8], delta, .big);
+ std.mem.writeInt(u64, body[14..22], timestamp_us, .big);
+ return output[0..self.mousePacketSize(22, false)];
+ }
+
+ fn mousePacketSize(self: *const Encoder, body_size: usize, batch: bool) usize {
+ return body_size + if (self.protocol_version >= 3) @as(usize, if (batch) 12 else 10) else 0;
+ }
+
+ fn mousePacket(self: *const Encoder, output: []u8, body_size: u16, batch: bool, timestamp_us: u64) ![]u8 {
+ const size = self.mousePacketSize(body_size, batch);
+ if (output.len < size) return error.NoSpace;
+ @memset(output[0..size], 0);
+ if (self.protocol_version >= 3) {
+ output[0] = 0x23;
+ std.mem.writeInt(u64, output[1..9], timestamp_us, .big);
+ output[9] = if (batch) 0x21 else 0x22;
+ if (batch) std.mem.writeInt(u16, output[10..12], body_size, .big);
+ }
+ return output[size - body_size .. size];
+ }
+
+ pub fn encodeReliableGamepad(
+ self: *const Encoder,
+ output: []u8,
+ state: GamepadState,
+ ) ![]const u8 {
+ const payload_offset: usize = if (self.protocol_version >= 3) reliable_wrapper_size else 0;
+ const packet_size = payload_offset + gamepad_packet_size;
+ if (output.len < packet_size) return error.NoSpace;
+ @memset(output[0..packet_size], 0);
+
+ if (self.protocol_version >= 3) {
+ output[0] = 0x23;
+ std.mem.writeInt(u64, output[1..9], state.timestamp_us, .big);
+ output[9] = 0x21;
+ std.mem.writeInt(u16, output[10..12], gamepad_packet_size, .big);
+ }
+ encodeGamepadBody(output[payload_offset..packet_size], state);
+ return output[0..packet_size];
+ }
+};
+
+pub const heartbeat_size: usize = 4;
+pub const MouseButton = enum(u8) { left = 1, right = 3 };
+pub const gamepad_packet_size: usize = 38;
+pub const maximum_packet_size: usize = reliable_wrapper_size + gamepad_packet_size;
+
+const reliable_wrapper_size: usize = 12;
+
+pub fn parseHandshakeVersion(data: []const u8) ?u16 {
+ if (data.len < 2) return null;
+ const first_word = std.mem.readInt(u16, data[0..2], .little);
+ if (first_word == 526) {
+ if (data.len < 4) return 2;
+ const version = std.mem.readInt(u16, data[2..4], .little);
+ return if (version == 2 or version == 3) version else null;
+ }
+ if (data[0] == 0x0e and (data[1] == 2 or data[1] == 3)) return data[1];
+ return null;
+}
+
+fn encodeGamepadBody(output: []u8, state: GamepadState) void {
+ std.debug.assert(output.len >= gamepad_packet_size);
+ std.mem.writeInt(u32, output[0..4], 12, .little);
+ std.mem.writeInt(u16, output[4..6], 26, .little);
+ std.mem.writeInt(u16, output[6..8], state.controller_id & 3, .little);
+ std.mem.writeInt(u16, output[8..10], state.gamepad_bitmap, .little);
+ std.mem.writeInt(u16, output[10..12], 20, .little);
+ std.mem.writeInt(u16, output[12..14], state.buttons, .little);
+ output[14] = state.left_trigger;
+ output[15] = state.right_trigger;
+ std.mem.writeInt(i16, output[16..18], state.left_x, .little);
+ std.mem.writeInt(i16, output[18..20], state.left_y, .little);
+ std.mem.writeInt(i16, output[20..22], state.right_x, .little);
+ std.mem.writeInt(i16, output[22..24], state.right_y, .little);
+ output[26] = 0x55;
+ std.mem.writeInt(u64, output[30..38], state.timestamp_us, .little);
+}
+
+test "heartbeat remains unwrapped for protocol version three" {
+ var encoder = Encoder{};
+ encoder.setProtocolVersion(3);
+ var output: [maximum_packet_size]u8 = undefined;
+ try std.testing.expectEqualSlices(
+ u8,
+ &.{ 0x02, 0x00, 0x00, 0x00 },
+ try encoder.encodeHeartbeat(&output),
+ );
+}
+
+test "protocol version two gamepad fields use the expected byte order" {
+ const encoder = Encoder{};
+ var output: [maximum_packet_size]u8 = undefined;
+ const packet = try encoder.encodeReliableGamepad(&output, .{
+ .controller_id = 7,
+ .gamepad_bitmap = 0x0305,
+ .buttons = 0xa55a,
+ .left_trigger = 0x12,
+ .right_trigger = 0xfe,
+ .left_x = -2,
+ .left_y = 0x1234,
+ .right_x = std.math.minInt(i16),
+ .right_y = std.math.maxInt(i16),
+ .timestamp_us = 0x0102030405060708,
+ });
+
+ try std.testing.expectEqual(gamepad_packet_size, packet.len);
+ try std.testing.expectEqual(@as(u32, 12), std.mem.readInt(u32, packet[0..4], .little));
+ try std.testing.expectEqual(@as(u16, 26), std.mem.readInt(u16, packet[4..6], .little));
+ try std.testing.expectEqual(@as(u16, 3), std.mem.readInt(u16, packet[6..8], .little));
+ try std.testing.expectEqual(@as(u16, 0x0305), std.mem.readInt(u16, packet[8..10], .little));
+ try std.testing.expectEqual(@as(u16, 0xa55a), std.mem.readInt(u16, packet[12..14], .little));
+ try std.testing.expectEqual(@as(i16, -2), std.mem.readInt(i16, packet[16..18], .little));
+ try std.testing.expectEqual(@as(u8, 0x55), packet[26]);
+ try std.testing.expectEqual(
+ @as(u64, 0x0102030405060708),
+ std.mem.readInt(u64, packet[30..38], .little),
+ );
+ for ([_]usize{ 24, 25, 27, 28, 29 }) |index|
+ try std.testing.expectEqual(@as(u8, 0), packet[index]);
+}
+
+test "default gamepad state identifies the first connected controller" {
+ const encoder = Encoder{};
+ var output: [maximum_packet_size]u8 = undefined;
+ const packet = try encoder.encodeReliableGamepad(&output, .{});
+
+ try std.testing.expectEqual(@as(u16, 0), std.mem.readInt(u16, packet[6..8], .little));
+ try std.testing.expectEqual(@as(u16, 0x0101), std.mem.readInt(u16, packet[8..10], .little));
+}
+
+test "protocol version three reliable gamepad wraps the complete legacy packet" {
+ var encoder = Encoder{};
+ encoder.setProtocolVersion(3);
+ var output: [maximum_packet_size]u8 = undefined;
+ const packet = try encoder.encodeReliableGamepad(&output, .{
+ .buttons = Button.a,
+ .timestamp_us = 0x0102030405060708,
+ });
+
+ try std.testing.expectEqual(@as(usize, 50), packet.len);
+ try std.testing.expectEqual(@as(u8, 0x23), packet[0]);
+ try std.testing.expectEqual(
+ @as(u64, 0x0102030405060708),
+ std.mem.readInt(u64, packet[1..9], .big),
+ );
+ try std.testing.expectEqual(@as(u8, 0x21), packet[9]);
+ try std.testing.expectEqual(@as(u16, 38), std.mem.readInt(u16, packet[10..12], .big));
+ try std.testing.expectEqual(@as(u32, 12), std.mem.readInt(u32, packet[12..16], .little));
+}
+
+test "parses both observed input-channel handshake formats" {
+ try std.testing.expectEqual(@as(?u16, 3), parseHandshakeVersion(&.{ 0x0e, 0x02, 0x03, 0x00 }));
+ try std.testing.expectEqual(@as(?u16, 2), parseHandshakeVersion(&.{ 0x0e, 0x02 }));
+ try std.testing.expectEqual(@as(?u16, 3), parseHandshakeVersion(&.{ 0x0e, 0x03 }));
+ try std.testing.expectEqual(@as(?u16, null), parseHandshakeVersion(&.{ 0x0e, 0xff }));
+ try std.testing.expectEqual(@as(?u16, null), parseHandshakeVersion(&.{ 0x01, 0x00 }));
+}
+
+test "absolute mouse packet uses big endian coordinates inside the legacy header" {
+ var output: [maximum_packet_size]u8 = undefined;
+ const encoder = Encoder{};
+ const packet = try encoder.encodeMousePosition(&output, 320, 240, 640, 480, 17);
+ try std.testing.expectEqual(@as(usize, 26), packet.len);
+ try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, packet[0..4], .little));
+ try std.testing.expectEqual(@as(u16, 320), std.mem.readInt(u16, packet[4..6], .big));
+ try std.testing.expectEqual(@as(u16, 240), std.mem.readInt(u16, packet[6..8], .big));
+ try std.testing.expectEqual(@as(u16, 640), std.mem.readInt(u16, packet[10..12], .big));
+ try std.testing.expectEqual(@as(u16, 480), std.mem.readInt(u16, packet[12..14], .big));
+ try std.testing.expectEqual(@as(u64, 17), std.mem.readInt(u64, packet[18..26], .big));
+ for ([_]usize{ 8, 9, 14, 15, 16, 17 }) |i| try std.testing.expectEqual(@as(u8, 0), packet[i]);
+ try std.testing.expectError(error.InvalidPointerDimensions, encoder.encodeMousePosition(&output, 0, 0, 0, 1, 0));
+ try std.testing.expectError(error.NoSpace, encoder.encodeMousePosition(output[0..25], 0, 0, 1, 1, 0));
+}
+
+test "v3 wraps mouse motion as a batch and button edges as single events" {
+ const encoder = Encoder{ .protocol_version = 3 };
+ var output: [maximum_packet_size]u8 = undefined;
+ const position = try encoder.encodeMousePosition(&output, 65535, 65535, 640, 480, 7);
+ try std.testing.expectEqual(@as(usize, 38), position.len);
+ try std.testing.expectEqual(@as(u8, 0x23), position[0]);
+ try std.testing.expectEqual(@as(u8, 0x21), position[9]);
+ try std.testing.expectEqual(@as(u16, 26), std.mem.readInt(u16, position[10..12], .big));
+ try std.testing.expectEqual(@as(u16, 639), std.mem.readInt(u16, position[16..18], .big));
+ try std.testing.expectEqual(@as(u16, 479), std.mem.readInt(u16, position[18..20], .big));
+ for ([_]bool{ true, false }) |down| {
+ const button = try encoder.encodeMouseButton(&output, .right, down, 123);
+ try std.testing.expectEqual(@as(usize, 28), button.len);
+ try std.testing.expectEqual(@as(u8, 0x22), button[9]);
+ try std.testing.expectEqual(@as(u32, if (down) 8 else 9), std.mem.readInt(u32, button[10..14], .little));
+ try std.testing.expectEqual(@as(u8, 3), button[14]);
+ try std.testing.expectEqual(@as(u64, 123), std.mem.readInt(u64, button[20..28], .big));
+ }
+ const wheel = try encoder.encodeMouseWheel(&output, -120, 321);
+ try std.testing.expectEqual(@as(usize, 32), wheel.len);
+ try std.testing.expectEqual(@as(i16, -120), std.mem.readInt(i16, wheel[16..18], .big));
+ try std.testing.expectEqual(@as(u64, 321), std.mem.readInt(u64, wheel[24..32], .big));
+}
diff --git a/src/provider/geforce_now/pointer_input.zig b/src/provider/geforce_now/pointer_input.zig
new file mode 100644
index 0000000..601a236
--- /dev/null
+++ b/src/provider/geforce_now/pointer_input.zig
@@ -0,0 +1,114 @@
+const std = @import("std");
+const protocol = @import("input_protocol.zig");
+const Button = protocol.Button;
+
+pub const State = struct {
+ enabled: bool = false,
+ x: f32 = 0.5,
+ y: f32 = 0.5,
+ buttons: u2 = 0,
+ chord_held: bool = false,
+ wait_for_release: bool = false,
+ previous_time: ?u64 = null,
+ next_scroll: u64 = 0,
+
+ pub const Update = struct {
+ gamepad: protocol.GamepadState,
+ moved: bool = false,
+ changed_buttons: u2 = 0,
+ wheel: i16 = 0,
+ toggled: bool = false,
+ };
+
+ pub fn update(self: *State, gamepad: protocol.GamepadState) Update {
+ const now = gamepad.timestamp_us;
+ const elapsed = if (self.previous_time) |previous| @min(now -| previous, 50_000) else 0;
+ self.previous_time = now;
+ const chord_mask = Button.back | Button.y;
+ const chord = gamepad.buttons & chord_mask == chord_mask;
+ var result = Update{ .gamepad = gamepad };
+ if (chord and !self.chord_held) {
+ self.enabled = !self.enabled;
+ self.wait_for_release = true;
+ result.toggled = true;
+ result.moved = self.enabled;
+ }
+ self.chord_held = chord;
+ if (self.wait_for_release and gamepad.buttons == 0) self.wait_for_release = false;
+ const old_buttons = self.buttons;
+ self.buttons = 0;
+ if (self.enabled or self.wait_for_release) {
+ result.gamepad = .{ .timestamp_us = now };
+ if (self.enabled and !self.wait_for_release) {
+ const dx = direction(gamepad.left_x, gamepad.buttons, Button.dpad_left, Button.dpad_right);
+ const dy = direction(-@as(i32, gamepad.left_y), gamepad.buttons, Button.dpad_up, Button.dpad_down);
+ const seconds = @as(f32, @floatFromInt(elapsed)) / 1_000_000;
+ const x = std.math.clamp(self.x + dx * seconds * 0.7, 0, 1);
+ const y = std.math.clamp(self.y + dy * seconds * 0.7, 0, 1);
+ result.moved = result.moved or x != self.x or y != self.y;
+ self.x = x;
+ self.y = y;
+ if (gamepad.buttons & Button.a != 0) self.buttons |= 1;
+ if (gamepad.buttons & Button.b != 0) self.buttons |= 2;
+ const scroll: i16 = @as(i16, @intFromBool(gamepad.buttons & Button.left_shoulder != 0)) -
+ @as(i16, @intFromBool(gamepad.buttons & Button.right_shoulder != 0));
+ if (scroll == 0) self.next_scroll = 0 else if (now >= self.next_scroll) {
+ result.wheel = scroll * 120;
+ self.next_scroll = now +| 150_000;
+ }
+ }
+ }
+ result.changed_buttons = old_buttons ^ self.buttons;
+ return result;
+ }
+};
+
+fn direction(axis: i32, buttons: u16, negative: u16, positive: u16) f32 {
+ const digital = @as(i32, @intFromBool(buttons & positive != 0)) - @as(i32, @intFromBool(buttons & negative != 0));
+ if (digital != 0) return @floatFromInt(digital);
+ const magnitude = @abs(axis);
+ if (magnitude <= 6000) return 0;
+ const strength = @as(f32, @floatFromInt(@min(magnitude, 32767) - 6000)) / 26767;
+ return (if (axis < 0) -strength else strength) * strength;
+}
+
+test "pointer chord toggles once and consumes controls until release" {
+ var pointer = State{};
+ const chord = protocol.GamepadState{ .buttons = Button.back | Button.y };
+ try std.testing.expect(pointer.update(chord).toggled);
+ try std.testing.expect(pointer.enabled);
+ try std.testing.expect(!pointer.update(chord).toggled);
+ try std.testing.expectEqual(@as(u16, 0), pointer.update(.{ .buttons = Button.y }).gamepad.buttons);
+ _ = pointer.update(.{});
+ const click = pointer.update(.{ .buttons = Button.a });
+ try std.testing.expectEqual(@as(u2, 1), click.changed_buttons);
+ try std.testing.expectEqual(@as(u16, 0), click.gamepad.buttons);
+ const exit = pointer.update(chord);
+ try std.testing.expect(!pointer.enabled);
+ try std.testing.expectEqual(@as(u2, 1), exit.changed_buttons);
+ _ = pointer.update(.{});
+ try std.testing.expectEqual(Button.a, pointer.update(.{ .buttons = Button.a }).gamepad.buttons);
+}
+
+test "pointer motion is time based, bounded and has a deadzone" {
+ var pointer = State{ .enabled = true };
+ _ = pointer.update(.{ .left_x = 5000 });
+ try std.testing.expect(!pointer.update(.{ .left_x = 5000, .timestamp_us = 20_000 }).moved);
+ _ = pointer.update(.{ .left_x = 32767, .left_y = 32767, .timestamp_us = 40_000 });
+ try std.testing.expect(pointer.x > 0.5 and pointer.y < 0.5);
+ const previous = pointer.x;
+ _ = pointer.update(.{ .left_x = 32767, .timestamp_us = 60_000_000 });
+ try std.testing.expect(pointer.x - previous <= 0.036);
+ pointer.x = 1;
+ _ = pointer.update(.{ .buttons = Button.dpad_right, .timestamp_us = 60_020_000 });
+ try std.testing.expectEqual(@as(f32, 1), pointer.x);
+}
+
+test "scroll repeats are bounded and disconnected controls release mouse buttons" {
+ var pointer = State{ .enabled = true };
+ try std.testing.expectEqual(@as(i16, -120), pointer.update(.{ .buttons = Button.right_shoulder }).wheel);
+ try std.testing.expectEqual(@as(i16, 0), pointer.update(.{ .buttons = Button.right_shoulder, .timestamp_us = 10_000 }).wheel);
+ try std.testing.expectEqual(@as(i16, -120), pointer.update(.{ .buttons = Button.right_shoulder, .timestamp_us = 150_000 }).wheel);
+ _ = pointer.update(.{ .buttons = Button.a | Button.b });
+ try std.testing.expectEqual(@as(u2, 3), pointer.update(.{}).changed_buttons);
+}
diff --git a/src/provider/geforce_now/provider_protocol.zig b/src/provider/geforce_now/provider_protocol.zig
new file mode 100644
index 0000000..4f25f57
--- /dev/null
+++ b/src/provider/geforce_now/provider_protocol.zig
@@ -0,0 +1,134 @@
+const std = @import("std");
+const endpoint_rules = @import("endpoint.zig");
+
+pub const service_urls_endpoint = "https://pcs.geforcenow.com/v1/serviceUrls";
+
+pub const Provider = struct {
+ allocator: std.mem.Allocator,
+ code: []u8,
+ display_name: []u8,
+ idp_id: []u8,
+ streaming_url: []u8,
+
+ pub fn deinit(self: *Provider) void {
+ self.allocator.free(self.code);
+ self.allocator.free(self.display_name);
+ self.allocator.free(self.idp_id);
+ self.allocator.free(self.streaming_url);
+ self.* = undefined;
+ }
+};
+
+pub fn parsePreferredProvider(allocator: std.mem.Allocator, data: []const u8) !Provider {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const root = try object(parsed.value);
+ const info = try object(root.get("gfnServiceInfo") orelse return error.MissingServiceInfo);
+ const endpoints = try array(info.get("gfnServiceEndpoints") orelse return error.MissingEndpoints);
+ if (endpoints.items.len == 0) return error.MissingEndpoints;
+
+ const default_code = optionalString(info, "defaultProvider") catch null;
+ var selected: ?std.json.ObjectMap = null;
+ if (default_code) |preferred| {
+ for (endpoints.items) |entry| {
+ const endpoint = object(entry) catch continue;
+ const code = optionalString(endpoint, "loginProviderCode") catch continue;
+ if (code != null and std.ascii.eqlIgnoreCase(code.?, preferred)) {
+ selected = endpoint;
+ break;
+ }
+ }
+ }
+ const provider_entry = selected orelse try object(endpoints.items[0]);
+
+ const code = try allocator.dupe(u8, try requiredString(provider_entry, "loginProviderCode"));
+ errdefer allocator.free(code);
+ const display_name = try allocator.dupe(u8, try requiredString(provider_entry, "loginProviderDisplayName"));
+ errdefer allocator.free(display_name);
+ const idp_id = try allocator.dupe(u8, try requiredString(provider_entry, "idpId"));
+ errdefer allocator.free(idp_id);
+ const raw_url = try requiredString(provider_entry, "streamingServiceUrl");
+ const streaming_url = try endpoint_rules.normalizeCloudMatchBase(allocator, raw_url);
+ errdefer allocator.free(streaming_url);
+
+ return .{
+ .allocator = allocator,
+ .code = code,
+ .display_name = display_name,
+ .idp_id = idp_id,
+ .streaming_url = streaming_url,
+ };
+}
+
+fn object(value: std.json.Value) !std.json.ObjectMap {
+ return switch (value) {
+ .object => |result| result,
+ else => error.ExpectedObject,
+ };
+}
+
+fn array(value: std.json.Value) !std.json.Array {
+ return switch (value) {
+ .array => |result| result,
+ else => error.ExpectedArray,
+ };
+}
+
+fn requiredString(value: std.json.ObjectMap, key: []const u8) ![]const u8 {
+ return (try optionalString(value, key)) orelse error.MissingField;
+}
+
+fn optionalString(value: std.json.ObjectMap, key: []const u8) !?[]const u8 {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .string => |text| if (text.len > 0) text else null,
+ .null => null,
+ else => error.InvalidField,
+ };
+}
+
+test "selects the advertised default provider" {
+ var provider = try parsePreferredProvider(std.testing.allocator,
+ \\{
+ \\ "gfnServiceInfo": {
+ \\ "defaultProvider": "BPC",
+ \\ "gfnServiceEndpoints": [
+ \\ {"loginProviderCode":"NVIDIA","loginProviderDisplayName":"NVIDIA","idpId":"one","streamingServiceUrl":"https://one.cloudmatchbeta.nvidiagrid.net/"},
+ \\ {"loginProviderCode":"BPC","loginProviderDisplayName":"bro.game","idpId":"two","streamingServiceUrl":"https://two.cloudmatchbeta.nvidiagrid.net"}
+ \\ ]
+ \\ }
+ \\}
+ );
+ defer provider.deinit();
+
+ try std.testing.expectEqualStrings("BPC", provider.code);
+ try std.testing.expectEqualStrings("bro.game", provider.display_name);
+ try std.testing.expectEqualStrings("two", provider.idp_id);
+ try std.testing.expectEqualStrings("https://two.cloudmatchbeta.nvidiagrid.net/", provider.streaming_url);
+}
+
+test "falls back to the first provider" {
+ var provider = try parsePreferredProvider(std.testing.allocator,
+ \\{"gfnServiceInfo":{"gfnServiceEndpoints":[{"loginProviderCode":"NVIDIA","loginProviderDisplayName":"NVIDIA","idpId":"idp","streamingServiceUrl":"https://prod.cloudmatchbeta.nvidiagrid.net/"}]}}
+ );
+ defer provider.deinit();
+ try std.testing.expectEqualStrings("NVIDIA", provider.code);
+}
+
+test "rejects an untrusted streaming service endpoint" {
+ try std.testing.expectError(
+ error.UntrustedCloudMatchUrl,
+ parsePreferredProvider(std.testing.allocator,
+ \\{"gfnServiceInfo":{"gfnServiceEndpoints":[{"loginProviderCode":"NVIDIA","loginProviderDisplayName":"NVIDIA","idpId":"idp","streamingServiceUrl":"https://example.invalid/"}]}}
+ ),
+ );
+}
+
+test "rejects an empty provider list" {
+ try std.testing.expectError(
+ error.MissingEndpoints,
+ parsePreferredProvider(std.testing.allocator,
+ \\{"gfnServiceInfo":{"gfnServiceEndpoints":[]}}
+ ),
+ );
+}
diff --git a/src/provider/geforce_now/sdp_protocol.zig b/src/provider/geforce_now/sdp_protocol.zig
new file mode 100644
index 0000000..b528462
--- /dev/null
+++ b/src/provider/geforce_now/sdp_protocol.zig
@@ -0,0 +1,499 @@
+const std = @import("std");
+
+const InputCapabilities = struct {
+ threshold_ms: u32 = 300,
+ hid_mask: u32 = std.math.maxInt(u32),
+ gamepad_mask: u32 = 0x0f,
+ partial_hid_mask: u32 = std.math.maxInt(u32),
+};
+
+const IceCredentials = struct {
+ username_fragment: []const u8,
+ password: []const u8,
+ fingerprint: []const u8,
+};
+
+pub fn sanitizeOffer(
+ allocator: std.mem.Allocator,
+ offer: []const u8,
+ media_ip: ?[]const u8,
+) ![]u8 {
+ if (offer.len == 0) return error.InvalidOffer;
+ var lines = std.ArrayList([]const u8).init(allocator);
+ defer lines.deinit();
+ var iterator = std.mem.splitScalar(u8, offer, '\n');
+ while (iterator.next()) |line| try lines.append(std.mem.trimRight(u8, line, "\r"));
+
+ const first_media = for (lines.items, 0..) |line, index| {
+ if (std.mem.startsWith(u8, line, "m=")) break index;
+ } else return error.MissingMedia;
+ var shared = std.ArrayList([]const u8).init(allocator);
+ defer shared.deinit();
+ for (lines.items[0..first_media]) |line| {
+ if (isSharedAttribute(line)) try shared.append(line);
+ }
+
+ var output = std.ArrayList(u8).init(allocator);
+ errdefer output.deinit();
+ const writer = output.writer();
+ for (lines.items, 0..) |line, index| {
+ try writeFixedAddress(writer, line, media_ip);
+ try writer.writeAll("\r\n");
+ if (!std.mem.startsWith(u8, line, "m=")) continue;
+ for (shared.items) |shared_attribute| {
+ if (sectionHasAttribute(lines.items, index, shared_attribute)) continue;
+ try writer.writeAll(shared_attribute);
+ try writer.writeAll("\r\n");
+ }
+ }
+ return output.toOwnedSlice();
+}
+
+pub fn buildNvstAnswer(
+ allocator: std.mem.Allocator,
+ answer: []const u8,
+ offer: []const u8,
+ width: u16,
+ height: u16,
+ frames_per_second: u16,
+ maximum_bitrate_kbps: u32,
+) ![]u8 {
+ if (width < 320 or height < 240 or frames_per_second == 0 or maximum_bitrate_kbps < 4000)
+ return error.InvalidStreamProfile;
+ const credentials = try extractIceCredentials(answer);
+ const input = parseInputCapabilities(offer);
+ const minimum_bitrate: u32 = 4000;
+ const initial_bitrate = @max(minimum_bitrate, maximum_bitrate_kbps / 4);
+ return std.fmt.allocPrint(
+ allocator,
+ "v=0\r\n" ++
+ "o=GreenOvercast 1 1 IN IP4 127.0.0.1\r\n" ++
+ "s=-\r\nt=0 0\r\n" ++
+ "a=general.icePassword:{s}\r\n" ++
+ "a=general.iceUserNameFragment:{s}\r\n" ++
+ "a=general.dtlsFingerprint:{s}\r\n" ++
+ "m=video 0 RTP/AVP\r\n" ++
+ "a=msid:fbc-video-0\r\n" ++
+ "a=video.enableRtpNack:1\r\n" ++
+ "a=video.packetSize:1140\r\n" ++
+ "a=video.rtpNackQueueLength:1024\r\n" ++
+ "a=video.rtpNackQueueMaxPackets:512\r\n" ++
+ "a=video.rtpNackMaxPacketCount:25\r\n" ++
+ "a=video.clientViewportWd:{d}\r\n" ++
+ "a=video.clientViewportHt:{d}\r\n" ++
+ "a=video.maxFPS:{d}\r\n" ++
+ "a=video.maxNumReferenceFrames:4\r\n" ++
+ "a=video.mapRtpTimestampsToFrames:1\r\n" ++
+ "a=video.bitDepth:8\r\n" ++
+ "a=video.initialBitrateKbps:{d}\r\n" ++
+ "a=video.initialPeakBitrateKbps:{d}\r\n" ++
+ "a=vqos.bw.minimumBitrateKbps:{d}\r\n" ++
+ "a=vqos.bw.maximumBitrateKbps:{d}\r\n" ++
+ "m=audio 0 RTP/AVP\r\n" ++
+ "a=msid:audio\r\n" ++
+ "m=application 0 RTP/AVP\r\n" ++
+ "a=msid:input_1\r\n" ++
+ "a=ri.partialReliableThresholdMs:{d}\r\n" ++
+ "a=ri.hidDeviceMask:{d}\r\n" ++
+ "a=ri.enablePartiallyReliableTransferGamepad:{d}\r\n" ++
+ "a=ri.enablePartiallyReliableTransferHid:{d}\r\n",
+ .{
+ credentials.password,
+ credentials.username_fragment,
+ credentials.fingerprint,
+ width,
+ height,
+ frames_per_second,
+ initial_bitrate,
+ initial_bitrate,
+ minimum_bitrate,
+ maximum_bitrate_kbps,
+ input.threshold_ms,
+ input.hid_mask,
+ input.gamepad_mask,
+ input.partial_hid_mask,
+ },
+ );
+}
+
+pub fn iceCandidateMid(sdp: []const u8, mid: ?[]const u8, media_index: ?u32) ![]const u8 {
+ if (mid) |value| {
+ if (value.len != 0) return value;
+ }
+ var remaining = media_index orelse return "0";
+ var in_section = false;
+ var lines = std.mem.splitScalar(u8, sdp, '\n');
+ while (lines.next()) |raw| {
+ const line = std.mem.trimRight(u8, raw, "\r");
+ if (std.mem.startsWith(u8, line, "m=")) {
+ if (in_section) break;
+ if (remaining == 0) {
+ in_section = true;
+ } else {
+ remaining -= 1;
+ }
+ } else if (in_section and std.mem.startsWith(u8, line, "a=mid:")) {
+ if (line.len == "a=mid:".len) break;
+ return line["a=mid:".len..];
+ }
+ }
+ return if (in_section) error.IceMediaIdMissing else error.IceMediaSectionNotFound;
+}
+
+pub fn codecPayloadType(sdp: []const u8, codec: []const u8) ?u8 {
+ var lines = std.mem.splitScalar(u8, sdp, '\n');
+ while (lines.next()) |raw_line| {
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (!std.mem.startsWith(u8, line, "a=rtpmap:")) continue;
+ const mapping = line["a=rtpmap:".len..];
+ const separator = std.mem.indexOfScalar(u8, mapping, ' ') orelse continue;
+ const payload_type = std.fmt.parseInt(u8, mapping[0..separator], 10) catch continue;
+ const encoding = mapping[separator + 1 ..];
+ if (std.ascii.startsWithIgnoreCase(encoding, codec)) return payload_type;
+ }
+ return null;
+}
+
+pub fn narrowAnswerToH264Opus(
+ allocator: std.mem.Allocator,
+ answer: []const u8,
+ offer: []const u8,
+) ![]u8 {
+ const video_payload = codecPayloadType(offer, "H264/") orelse return error.H264NotOffered;
+ const audio_payload = codecPayloadType(offer, "OPUS/") orelse return error.OpusNotOffered;
+
+ const Media = enum { other, video, audio };
+ var media = Media.other;
+ var output = std.ArrayList(u8).init(allocator);
+ errdefer output.deinit();
+ const writer = output.writer();
+
+ var lines = std.mem.splitScalar(u8, answer, '\n');
+ while (lines.next()) |raw_line| {
+ const line = std.mem.trimRight(u8, raw_line, "\r");
+ if (line.len == 0) continue;
+
+ if (std.mem.startsWith(u8, line, "m=")) {
+ if (std.mem.startsWith(u8, line, "m=video ")) {
+ media = .video;
+ try writeSinglePayloadMediaLine(writer, line, video_payload);
+ continue;
+ }
+ if (std.mem.startsWith(u8, line, "m=audio ")) {
+ media = .audio;
+ try writeSinglePayloadMediaLine(writer, line, audio_payload);
+ continue;
+ }
+ media = .other;
+ }
+
+ const selected_payload = switch (media) {
+ .video => video_payload,
+ .audio => audio_payload,
+ .other => null,
+ };
+ if (selected_payload) |payload| {
+ if (codecAttributePayload(line)) |attribute_payload| {
+ if (attribute_payload != payload) continue;
+ }
+ }
+ try writer.writeAll(line);
+ try writer.writeAll("\r\n");
+ }
+ return output.toOwnedSlice();
+}
+
+fn writeSinglePayloadMediaLine(writer: anytype, line: []const u8, payload: u8) !void {
+ var fields = std.mem.tokenizeScalar(u8, line, ' ');
+ const kind = fields.next() orelse return error.InvalidMediaLine;
+ const port = fields.next() orelse return error.InvalidMediaLine;
+ const protocol = fields.next() orelse return error.InvalidMediaLine;
+ try writer.print("{s} {s} {s} {d}\r\n", .{ kind, port, protocol, payload });
+}
+
+fn codecAttributePayload(line: []const u8) ?u8 {
+ const prefixes = [_][]const u8{ "a=rtpmap:", "a=fmtp:", "a=rtcp-fb:" };
+ for (prefixes) |prefix| {
+ if (!std.mem.startsWith(u8, line, prefix)) continue;
+ const value = line[prefix.len..];
+ if (value.len == 0 or value[0] == '*') return null;
+ const end = std.mem.indexOfAny(u8, value, " \t") orelse value.len;
+ return std.fmt.parseUnsigned(u8, value[0..end], 10) catch null;
+ }
+ return null;
+}
+
+pub fn mediaHostCandidate(
+ allocator: std.mem.Allocator,
+ address: []const u8,
+ port: u16,
+) !?[:0]u8 {
+ if (port <= 1024 or port == 443) return null;
+
+ var address_buffer: [15]u8 = undefined;
+ const ip = publicIpv4(address, &address_buffer) orelse return null;
+ return try std.fmt.allocPrintZ(
+ allocator,
+ "candidate:1 1 UDP 2122260223 {s} {d} typ host",
+ .{ ip, port },
+ );
+}
+
+fn extractIceCredentials(sdp: []const u8) !IceCredentials {
+ return .{
+ .username_fragment = attribute(sdp, "a=ice-ufrag:") orelse
+ return error.MissingIceUsername,
+ .password = attribute(sdp, "a=ice-pwd:") orelse
+ return error.MissingIcePassword,
+ .fingerprint = attribute(sdp, "a=fingerprint:") orelse
+ return error.MissingFingerprint,
+ };
+}
+
+fn parseInputCapabilities(sdp: []const u8) InputCapabilities {
+ var result = InputCapabilities{};
+ result.threshold_ms = integerAttribute(sdp, "ri.partialReliableThresholdMs") orelse result.threshold_ms;
+ result.hid_mask = integerAttribute(sdp, "ri.hidDeviceMask") orelse result.hid_mask;
+ result.gamepad_mask = integerAttribute(sdp, "ri.enablePartiallyReliableTransferGamepad") orelse result.gamepad_mask;
+ result.partial_hid_mask = integerAttribute(sdp, "ri.enablePartiallyReliableTransferHid") orelse result.partial_hid_mask;
+ result.threshold_ms = std.math.clamp(result.threshold_ms, 1, 5000);
+ return result;
+}
+
+fn integerAttribute(sdp: []const u8, name: []const u8) ?u32 {
+ var lines = std.mem.splitScalar(u8, sdp, '\n');
+ while (lines.next()) |raw_line| {
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (!std.mem.startsWith(u8, line, "a=")) continue;
+ const value_start = 2 + name.len + 1;
+ if (line.len <= value_start or !std.mem.eql(u8, line[2 .. 2 + name.len], name) or
+ line[2 + name.len] != ':') continue;
+ const value = std.mem.trim(u8, line[value_start..], " \t");
+ if (std.mem.startsWith(u8, value, "0x") or std.mem.startsWith(u8, value, "0X"))
+ return std.fmt.parseInt(u32, value[2..], 16) catch null;
+ return std.fmt.parseInt(u32, value, 10) catch null;
+ }
+ return null;
+}
+
+fn attribute(sdp: []const u8, prefix: []const u8) ?[]const u8 {
+ var lines = std.mem.splitScalar(u8, sdp, '\n');
+ while (lines.next()) |raw_line| {
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (std.mem.startsWith(u8, line, prefix) and line.len > prefix.len)
+ return line[prefix.len..];
+ }
+ return null;
+}
+
+fn publicIpv4(address: []const u8, buffer: *[15]u8) ?[]const u8 {
+ _ = std.net.Ip4Address.parse(address, 0) catch {
+ const label_end = std.mem.indexOfScalar(u8, address, '.') orelse address.len;
+ var parts = std.mem.splitScalar(u8, address[0..label_end], '-');
+ var octets: [4]u8 = undefined;
+ for (&octets) |*octet| {
+ const part = parts.next() orelse return null;
+ octet.* = std.fmt.parseInt(u8, part, 10) catch return null;
+ }
+ if (parts.next() != null) return null;
+ return std.fmt.bufPrint(buffer, "{d}.{d}.{d}.{d}", .{
+ octets[0], octets[1], octets[2], octets[3],
+ }) catch return null;
+ };
+ return address;
+}
+
+fn isSharedAttribute(line: []const u8) bool {
+ return std.mem.startsWith(u8, line, "a=ice-ufrag:") or
+ std.mem.startsWith(u8, line, "a=ice-pwd:") or
+ std.mem.startsWith(u8, line, "a=fingerprint:") or
+ std.mem.startsWith(u8, line, "a=setup:");
+}
+
+fn sectionHasAttribute(lines: []const []const u8, start: usize, attribute_line: []const u8) bool {
+ const prefix = attribute_line[0 .. std.mem.indexOfScalar(u8, attribute_line, ':').? + 1];
+ var index = start + 1;
+ while (index < lines.len and !std.mem.startsWith(u8, lines[index], "m=")) : (index += 1) {
+ if (std.mem.startsWith(u8, lines[index], prefix)) return true;
+ }
+ return false;
+}
+
+fn writeFixedAddress(writer: anytype, line: []const u8, media_ip: ?[]const u8) !void {
+ const ip = media_ip orelse return writer.writeAll(line);
+ if (std.mem.eql(u8, line, "c=IN IP4 0.0.0.0"))
+ return writer.print("c=IN IP4 {s}", .{ip});
+ if (!std.mem.startsWith(u8, line, "a=candidate:")) return writer.writeAll(line);
+
+ var tokens = std.mem.tokenizeScalar(u8, line, ' ');
+ var index: usize = 0;
+ var first = true;
+ while (tokens.next()) |token| : (index += 1) {
+ if (!first) try writer.writeByte(' ');
+ first = false;
+ try writer.writeAll(if (index == 4 and std.mem.eql(u8, token, "0.0.0.0")) ip else token);
+ }
+}
+
+test "offer sanitation fixes the media address and copies shared attributes" {
+ const offer =
+ "v=0\r\n" ++
+ "a=ice-ufrag:remote\r\n" ++
+ "a=ice-pwd:secret\r\n" ++
+ "a=fingerprint:sha-256 AA:BB\r\n" ++
+ "a=setup:actpass\r\n" ++
+ "m=video 9 UDP/TLS/RTP/SAVPF 102\r\n" ++
+ "c=IN IP4 0.0.0.0\r\n" ++
+ "a=candidate:1 1 udp 1 0.0.0.0 5000 typ host\r\n";
+ const result = try sanitizeOffer(std.testing.allocator, offer, "192.0.2.10");
+ defer std.testing.allocator.free(result);
+ try std.testing.expect(std.mem.indexOf(u8, result, "c=IN IP4 192.0.2.10") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, " 192.0.2.10 5000 ") != null);
+ try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, result, "a=ice-ufrag:remote"));
+}
+
+test "media overrides preserve the remaining session attributes" {
+ const offer = "v=0\r\n" ++
+ "a=ice-ufrag:shared\r\na=ice-pwd:password\r\n" ++
+ "a=fingerprint:sha-256 AA:BB\r\na=setup:actpass\r\n" ++
+ "m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=setup:passive\r\n" ++
+ "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=ice-ufrag:audio\r\n";
+ const result = try sanitizeOffer(std.testing.allocator, offer, null);
+ defer std.testing.allocator.free(result);
+ const video_start = std.mem.indexOf(u8, result, "m=video").?;
+ const audio_start = std.mem.indexOf(u8, result, "m=audio").?;
+ const video = result[video_start..audio_start];
+ const audio = result[audio_start..];
+ try std.testing.expect(std.mem.indexOf(u8, video, "a=setup:actpass") == null);
+ try std.testing.expect(std.mem.indexOf(u8, video, "a=setup:passive") != null);
+ try std.testing.expect(std.mem.indexOf(u8, video, "a=ice-ufrag:shared") != null);
+ try std.testing.expect(std.mem.indexOf(u8, video, "a=ice-pwd:password") != null);
+ try std.testing.expect(std.mem.indexOf(u8, video, "a=fingerprint:sha-256 AA:BB") != null);
+ try std.testing.expect(std.mem.indexOf(u8, audio, "a=ice-ufrag:shared") == null);
+ try std.testing.expect(std.mem.indexOf(u8, audio, "a=ice-ufrag:audio") != null);
+ try std.testing.expect(std.mem.indexOf(u8, audio, "a=ice-pwd:password") != null);
+ try std.testing.expect(std.mem.indexOf(u8, audio, "a=setup:actpass") != null);
+}
+
+test "NVST answer uses negotiated dimensions and remote input capabilities" {
+ const answer =
+ "v=0\r\na=ice-ufrag:local\r\na=ice-pwd:password\r\n" ++
+ "a=fingerprint:sha-256 CC:DD\r\n";
+ const offer = "a=ri.partialReliableThresholdMs:250\r\n" ++
+ "a=ri.enablePartiallyReliableTransferGamepad:0x03\r\n";
+ const result = try buildNvstAnswer(std.testing.allocator, answer, offer, 640, 480, 30, 6000);
+ defer std.testing.allocator.free(result);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=video.clientViewportWd:640") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=ri.partialReliableThresholdMs:250") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=ri.enablePartiallyReliableTransferGamepad:3") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=video.initialBitrateKbps:4000\r\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=video.initialPeakBitrateKbps:4000\r\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=vqos.bw.minimumBitrateKbps:4000\r\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=vqos.bw.maximumBitrateKbps:6000\r\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "enableBandwidthEstimation") == null);
+ try std.testing.expectError(error.InvalidStreamProfile, buildNvstAnswer(std.testing.allocator, answer, offer, 640, 480, 30, 2000));
+}
+
+test "codec payload types are matched without assuming Xbox values" {
+ const sdp = "a=rtpmap:96 H264/90000\r\na=rtpmap:109 opus/48000/2\r\n";
+ try std.testing.expectEqual(@as(?u8, 96), codecPayloadType(sdp, "h264/"));
+ try std.testing.expectEqual(@as(?u8, 109), codecPayloadType(sdp, "OPUS/"));
+}
+
+test "answer advertises only H264 and Opus payloads" {
+ const offer =
+ "m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100\r\n" ++
+ "a=rtpmap:96 H264/90000\r\n" ++
+ "a=rtpmap:97 rtx/90000\r\n" ++
+ "a=fmtp:97 apt=96\r\n" ++
+ "a=rtpmap:98 H265/90000\r\n" ++
+ "a=rtpmap:99 rtx/90000\r\n" ++
+ "a=fmtp:99 apt=98\r\n" ++
+ "m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0\r\n" ++
+ "a=rtpmap:111 opus/48000/2\r\n";
+ const answer =
+ "v=0\r\n" ++
+ "m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100\r\n" ++
+ "a=mid:1\r\n" ++
+ "a=rtpmap:96 H264/90000\r\n" ++
+ "a=fmtp:96 packetization-mode=1\r\n" ++
+ "a=rtcp-fb:96 nack pli\r\n" ++
+ "a=rtpmap:98 H265/90000\r\n" ++
+ "a=fmtp:99 apt=98\r\n" ++
+ "a=rtcp-fb:* transport-cc\r\n" ++
+ "m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0\r\n" ++
+ "a=mid:0\r\n" ++
+ "a=rtpmap:111 opus/48000/2\r\n" ++
+ "a=rtpmap:63 red/48000/2\r\n" ++
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" ++
+ "a=mid:2\r\n";
+ const result = try narrowAnswerToH264Opus(std.testing.allocator, answer, offer);
+ defer std.testing.allocator.free(result);
+ try std.testing.expect(std.mem.indexOf(u8, result, "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "H265/") == null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "red/48000") == null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "a=rtcp-fb:* transport-cc") != null);
+ try std.testing.expect(std.mem.indexOf(u8, result, "m=application 9 UDP/DTLS/SCTP webrtc-datachannel") != null);
+}
+
+test "media endpoint becomes a UDP host candidate" {
+ const candidate = (try mediaHostCandidate(
+ std.testing.allocator,
+ "203.0.113.10",
+ 49005,
+ )).?;
+ defer std.testing.allocator.free(candidate);
+ try std.testing.expectEqualStrings(
+ "candidate:1 1 UDP 2122260223 203.0.113.10 49005 typ host",
+ candidate,
+ );
+}
+
+test "ICE media indices resolve to SDP mids rather than numeric identifiers" {
+ const offer = "v=0\r\na=mid:session\r\n" ++
+ "m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=mid:video-main\r\n" ++
+ "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:42\r\n" ++
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:input\r\n";
+ try std.testing.expectEqualStrings("video-main", try iceCandidateMid(offer, null, 0));
+ try std.testing.expectEqualStrings("42", try iceCandidateMid(offer, null, 1));
+ try std.testing.expectEqualStrings("input", try iceCandidateMid(offer, "", 2));
+ try std.testing.expectEqualStrings("explicit", try iceCandidateMid(offer, "explicit", 100));
+ try std.testing.expectEqualStrings("0", try iceCandidateMid(offer, null, null));
+ try std.testing.expectError(error.IceMediaSectionNotFound, iceCandidateMid(offer, null, 3));
+ try std.testing.expectError(error.IceMediaSectionNotFound, iceCandidateMid(offer, null, std.math.maxInt(u32)));
+}
+
+test "ICE media lookup does not borrow a mid from a different section" {
+ const offer = "v=0\na=mid:session\n" ++
+ "m=video 0 RTP/AVP 96\n" ++
+ "m=audio 9 RTP/AVP 111\na=mid:audio\n" ++
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\na=mid:\n";
+ try std.testing.expectError(error.IceMediaIdMissing, iceCandidateMid(offer, null, 0));
+ try std.testing.expectEqualStrings("audio", try iceCandidateMid(offer, null, 1));
+ try std.testing.expectError(error.IceMediaIdMissing, iceCandidateMid(offer, null, 2));
+ try std.testing.expectError(error.IceMediaSectionNotFound, iceCandidateMid("", null, 0));
+}
+
+test "media candidate accepts an Alliance host and rejects control ports" {
+ const candidate = (try mediaHostCandidate(
+ std.testing.allocator,
+ "203-0-113-10.cloudmatch.example",
+ 49005,
+ )).?;
+ defer std.testing.allocator.free(candidate);
+ try std.testing.expectEqualStrings(
+ "candidate:1 1 UDP 2122260223 203.0.113.10 49005 typ host",
+ candidate,
+ );
+ try std.testing.expect((try mediaHostCandidate(
+ std.testing.allocator,
+ "203.0.113.10",
+ 443,
+ )) == null);
+ try std.testing.expect((try mediaHostCandidate(
+ std.testing.allocator,
+ "not-an-address",
+ 49005,
+ )) == null);
+}
diff --git a/src/provider/geforce_now/session_client.zig b/src/provider/geforce_now/session_client.zig
new file mode 100644
index 0000000..cb98eed
--- /dev/null
+++ b/src/provider/geforce_now/session_client.zig
@@ -0,0 +1,840 @@
+const std = @import("std");
+const auth_client = @import("auth_client.zig");
+const protocol = @import("cloudmatch_protocol.zig");
+const subscription = @import("subscription_protocol.zig");
+const uuid = @import("uuid");
+const ads = @import("ad_protocol.zig");
+
+pub const AdHandler = struct {
+ context: *anyopaque,
+ run: *const fn (*anyopaque, *Client, ads.Opportunity, u8) anyerror!void,
+};
+
+const c = @cImport({
+ @cInclude("handheld_ui.h");
+ @cInclude("http_client.h");
+});
+
+const maximum_create_attempts = 6;
+const client_version = "2.0.80.173";
+const user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " ++
+ "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 NVIDIACEFClient/HEAD/debb5919f6 " ++
+ "GFN-PC/" ++ client_version;
+
+const fallback_stream_width: u16 = 1024;
+const fallback_stream_height: u16 = 768;
+const preferred_frames_per_second: u16 = 30;
+const maximum_stream_width: u16 = 1280;
+const maximum_stream_height: u16 = 768;
+const maximum_bearer_length = 64 * 1024;
+
+pub const Client = struct {
+ allocator: std.mem.Allocator,
+ auth: *auth_client.Client,
+ ui: *c.GoHandheldUi,
+ client_id: [uuid.string_length + 1]u8,
+ session: ?protocol.Session = null,
+ streaming_url: ?[]u8 = null,
+ last_http_status: c_long = 0,
+ last_response_retryable: ?bool = null,
+ stream_width: u16 = fallback_stream_width,
+ stream_height: u16 = fallback_stream_height,
+ stream_frames_per_second: u16 = preferred_frames_per_second,
+ ad_handler: ?AdHandler = null,
+ ad_stop: ?*std.atomic.Value(bool) = null,
+ completed_ads: std.ArrayListUnmanaged([]u8) = .{},
+ app_id: ?[]u8 = null,
+
+ pub fn create(
+ allocator: std.mem.Allocator,
+ auth: *auth_client.Client,
+ ui_pointer: *anyopaque,
+ ) !*Client {
+ const client = try allocator.create(Client);
+ client.* = .{
+ .allocator = allocator,
+ .auth = auth,
+ .ui = @ptrCast(ui_pointer),
+ .client_id = undefined,
+ };
+ uuid.generate(&client.client_id);
+ return client;
+ }
+
+ pub fn destroy(self: *Client) void {
+ if (self.app_id) |id| self.allocator.free(id);
+ self.clearAds();
+ if (self.session) |*session| session.deinit();
+ if (self.streaming_url) |url| self.allocator.free(url);
+ const allocator = self.allocator;
+ allocator.destroy(self);
+ }
+
+ pub fn start(
+ self: *Client,
+ app_id: []const u8,
+ internal_title: []const u8,
+ width: u16,
+ height: u16,
+ ) !void {
+ if (self.session != null) return error.SessionAlreadyActive;
+ self.clearAds();
+ uuid.generate(&self.client_id);
+ if (self.app_id) |id| self.allocator.free(id);
+ self.app_id = null;
+ self.app_id = try self.allocator.dupe(u8, app_id);
+ const bearer = self.auth.bearer() orelse return error.MissingCredentials;
+ const provider_url = self.auth.streamingUrl() orelse return error.MissingProvider;
+ const user_age = try self.auth.fetchUserAge();
+ try self.selectStreamMode(provider_url, bearer, width, height);
+ if (self.streaming_url) |url| self.allocator.free(url);
+ self.streaming_url = null;
+ self.streaming_url = self.resolveStreamingUrl(provider_url, bearer) catch |err| fallback: {
+ if (err == error.Cancelled) return err;
+ std.debug.print("GeForce NOW region discovery failed: {s}\n", .{@errorName(err)});
+ break :fallback try self.allocator.dupe(u8, provider_url);
+ };
+ const local_url = self.streaming_url.?;
+ if (!std.mem.eql(u8, local_url, provider_url))
+ std.debug.print("GeForce NOW local streaming region selected\n", .{});
+
+ const body = try protocol.buildSessionRequest(
+ self.allocator,
+ app_id,
+ internal_title,
+ self.auth.stableDeviceId(),
+ self.stream_width,
+ self.stream_height,
+ self.stream_frames_per_second,
+ user_age,
+ );
+ defer self.allocator.free(body);
+ self.session = self.createSessionWithRetry(local_url, body, bearer) catch |err| retry: {
+ if (err == error.Cancelled) return err;
+ if (std.mem.eql(u8, local_url, provider_url) or
+ !retryableRequestFailure(
+ err,
+ self.last_http_status,
+ self.last_response_retryable,
+ ))
+ {
+ self.logActiveSessions(provider_url, bearer, app_id);
+ return err;
+ }
+ std.debug.print("GeForce NOW local region rejected the session; retrying provider endpoint\n", .{});
+ const fallback_url = try self.allocator.dupe(u8, provider_url);
+ self.allocator.free(self.streaming_url.?);
+ self.streaming_url = fallback_url;
+ break :retry self.createSession(provider_url, body, bearer) catch |provider_error| {
+ self.logActiveSessions(provider_url, bearer, app_id);
+ return provider_error;
+ };
+ };
+ }
+
+ pub fn waitUntilReady(self: *Client) !void {
+ var consecutive_server_errors: usize = 0;
+ while (true) {
+ const current = if (self.session) |*session| session else return error.MissingSession;
+ if (current.ended()) return error.SessionEnded;
+ if (current.ads) |admission| {
+ if (admission.required) for (admission.opportunities) |opportunity| {
+ var handled = false;
+ for (self.completed_ads.items) |id| if (std.mem.eql(u8, id, opportunity.id)) {
+ handled = true;
+ break;
+ };
+ if (handled) continue;
+ if (self.completed_ads.items.len == 64) return error.TooManyAdOpportunities;
+ const handler = self.ad_handler orelse return error.AdPlayerUnavailable;
+ const id = try self.allocator.dupe(u8, opportunity.id);
+ errdefer self.allocator.free(id);
+ try handler.run(handler.context, self, opportunity, admission.provider);
+ try self.completed_ads.append(self.allocator, id);
+ };
+ if (current.ready() and current.signaling_url != null and !admission.required) return;
+ } else if (current.ready() and current.signaling_url != null) return;
+ self.drawProgress(current);
+ if (c.go_handheld_ui_wait(self.ui, pollDelay(consecutive_server_errors)) != 0)
+ return error.Cancelled;
+
+ const bearer = self.auth.bearer() orelse return error.MissingCredentials;
+ const base = self.streaming_url orelse return error.MissingProvider;
+ var url_buffer: [768]u8 = undefined;
+ const url = try std.fmt.bufPrintZ(
+ &url_buffer,
+ "{s}v2/session/{s}",
+ .{ base, current.id },
+ );
+ const response = self.request("GET", url, null, bearer) catch |err| {
+ if (!retryableRequestFailure(err, self.last_http_status, self.last_response_retryable))
+ return err;
+ consecutive_server_errors += 1;
+ if (consecutive_server_errors > 12) return error.SessionPollFailed;
+ continue;
+ };
+ defer c.go_http_response_destroy(response);
+ consecutive_server_errors = 0;
+ const next = try protocol.parseSession(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ );
+ current.deinit();
+ self.session = next;
+ }
+ }
+
+ pub fn stop(self: *Client) !void {
+ const session = if (self.session) |*value| value else return;
+ defer {
+ session.deinit();
+ self.session = null;
+ }
+ const bearer = self.auth.bearer() orelse return error.MissingCredentials;
+ const base = self.streaming_url orelse return error.MissingProvider;
+ var url_buffer: [768]u8 = undefined;
+ const url = try std.fmt.bufPrintZ(
+ &url_buffer,
+ "{s}v2/session/{s}",
+ .{ base, session.id },
+ );
+ const response = try self.request("DELETE", url, null, bearer);
+ c.go_http_response_destroy(response);
+ }
+
+ fn clearAds(self: *Client) void {
+ for (self.completed_ads.items) |id| self.allocator.free(id);
+ self.completed_ads.deinit(self.allocator);
+ self.completed_ads = .{};
+ }
+
+ pub fn reportAd(self: *Client, id: []const u8, update: ads.Update) !void {
+ const session = self.session orelse return error.MissingSession;
+ const admission = session.ads orelse return error.MissingAdMetadata;
+ const base = admission.control_base orelse self.streaming_url orelse return error.MissingProvider;
+ const bearer = self.auth.bearer() orelse return error.MissingCredentials;
+ const body = try ads.buildUpdate(self.allocator, id, update, std.time.timestamp());
+ defer self.allocator.free(body);
+ var buffer: [1024]u8 = undefined;
+ const url = try std.fmt.bufPrintZ(&buffer, "{s}v2/session/{s}", .{ base, session.id });
+ const response = try self.request("PUT", url, body, bearer);
+ defer c.go_http_response_destroy(response);
+ const parsed = try std.json.parseFromSlice(std.json.Value, self.allocator, response.*.data[0..response.*.len], .{});
+ defer parsed.deinit();
+ if (parsed.value != .object) return error.AdUpdateRejected;
+ const status = parsed.value.object.get("requestStatus") orelse return error.AdUpdateRejected;
+ if (status != .object) return error.AdUpdateRejected;
+ const code = status.object.get("statusCode") orelse return error.AdUpdateRejected;
+ if (code != .integer or code.integer != 1) return error.AdUpdateRejected;
+ }
+
+ pub fn sessionInfo(self: *Client) ?*const protocol.Session {
+ return if (self.session) |*session| session else null;
+ }
+
+ pub fn clientId(self: *const Client) []const u8 {
+ return std.mem.sliceTo(&self.client_id, 0);
+ }
+
+ pub fn streamWidth(self: *const Client) u16 {
+ return self.stream_width;
+ }
+
+ pub fn streamHeight(self: *const Client) u16 {
+ return self.stream_height;
+ }
+
+ pub fn streamFramesPerSecond(self: *const Client) u16 {
+ return self.stream_frames_per_second;
+ }
+
+ fn request(
+ self: *Client,
+ method: [*:0]const u8,
+ url: [:0]const u8,
+ body: ?[]const u8,
+ bearer: []const u8,
+ ) ![*c]c.GoHttpResponse {
+ const terminated_body = if (body) |value| try self.allocator.dupeZ(u8, value) else null;
+ defer if (terminated_body) |value| self.allocator.free(value);
+ const authorization = try authorizationHeader(self.allocator, bearer);
+ defer secureFree(self.allocator, authorization);
+ var dynamic_headers: [2][256]u8 = undefined;
+ var headers = [_][*c]const u8{
+ authorization.ptr,
+ try header(&dynamic_headers[0], "nv-client-id", null, self.auth.protocolClientId()),
+ try header(&dynamic_headers[1], "x-device-id", null, self.auth.stableDeviceId()),
+ "Accept: application/json",
+ "Content-Type: application/json",
+ "Connection: close",
+ "Origin: https://play.geforcenow.com",
+ "Referer: https://play.geforcenow.com/",
+ "nv-browser-type: CHROME",
+ "nv-client-streamer: NVIDIA-CLASSIC",
+ "nv-client-type: NATIVE",
+ "nv-client-version: 30.0",
+ "nv-device-make: UNKNOWN",
+ "nv-device-model: UNKNOWN",
+ "nv-device-os: WINDOWS",
+ "nv-device-type: DESKTOP",
+ "User-Agent: " ++ user_agent,
+ };
+ self.last_http_status = 0;
+ self.last_response_retryable = null;
+ // Session deletion must finish after a local stop request.
+ const cancelable = !std.mem.eql(u8, std.mem.span(method), "DELETE");
+ const response = c.go_http_request_bounded_cancelable(
+ method,
+ url.ptr,
+ if (terminated_body) |value| value.ptr else null,
+ @ptrCast(&headers),
+ headers.len,
+ 8 * 1024 * 1024,
+ if (cancelable) requestCancelled else null,
+ self,
+ );
+ if (response == null) {
+ if (cancelable and c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled;
+ return error.HttpRequestFailed;
+ }
+ self.last_http_status = response.*.status;
+ if (response.*.status < 200 or response.*.status >= 300 or
+ (cancelable and response.*.data == null))
+ {
+ const status = response.*.status;
+ std.debug.print("GeForce NOW session request failed: HTTP {d}\n", .{status});
+ if (response.*.data != null) {
+ self.last_response_retryable = protocol.sessionFailureIsRetryable(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ );
+ var summary_buffer: [384]u8 = undefined;
+ if (protocol.writeErrorSummary(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ &summary_buffer,
+ )) |summary| std.debug.print("GeForce NOW session error: {s}\n", .{summary});
+ }
+ c.go_http_response_destroy(response);
+ return error.HttpRequestRejected;
+ }
+ return response;
+ }
+
+ fn createSession(
+ self: *Client,
+ base: []const u8,
+ body: []const u8,
+ bearer: []const u8,
+ ) !protocol.Session {
+ var url_buffer: [768]u8 = undefined;
+ const url = try buildCreateSessionUrl(&url_buffer, base);
+ const response = try self.request("POST", url, body, bearer);
+ defer c.go_http_response_destroy(response);
+ return protocol.parseSession(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ );
+ }
+
+ fn createSessionWithRetry(
+ self: *Client,
+ base: []const u8,
+ body: []const u8,
+ bearer: []const u8,
+ ) !protocol.Session {
+ var attempt: usize = 0;
+ while (attempt < maximum_create_attempts) : (attempt += 1) {
+ if (self.createSession(base, body, bearer)) |session| {
+ return session;
+ } else |err| {
+ if (err == error.Cancelled or
+ attempt + 1 >= maximum_create_attempts or
+ !retryableRequestFailure(
+ err,
+ self.last_http_status,
+ self.last_response_retryable,
+ ))
+ return err;
+
+ const delay = createRetryDelay(attempt);
+ std.debug.print(
+ "GeForce NOW session creation retry {d}/{d} in {d} seconds\n",
+ .{ attempt + 2, maximum_create_attempts, delay / 1000 },
+ );
+ c.go_handheld_ui_draw_loading(
+ self.ui,
+ "NVIDIA SERVER BUSY",
+ "RETRYING SESSION REQUEST",
+ c.GO_HANDHELD_UI_ACTION_CANCEL,
+ );
+ if (c.go_handheld_ui_wait(self.ui, delay) != 0)
+ return error.Cancelled;
+ }
+ }
+ unreachable;
+ }
+
+ fn logActiveSessions(
+ self: *Client,
+ base: []const u8,
+ bearer: []const u8,
+ app_id: []const u8,
+ ) void {
+ var url_buffer: [768]u8 = undefined;
+ const url = std.fmt.bufPrintZ(&url_buffer, "{s}v2/session", .{base}) catch return;
+ const response = self.request("GET", url, null, bearer) catch return;
+ defer c.go_http_response_destroy(response);
+ const summary = protocol.parseActiveSessionSummary(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ self.auth.stableDeviceId(),
+ app_id,
+ ) catch return;
+ std.debug.print(
+ "GeForce NOW active sessions: {d} total, {d} from this device, {d} for this game\n",
+ .{ summary.active, summary.same_device, summary.matching_app },
+ );
+ }
+
+ fn selectStreamMode(
+ self: *Client,
+ provider_url: []const u8,
+ bearer: []const u8,
+ display_width: u16,
+ display_height: u16,
+ ) !void {
+ self.stream_width = fallback_stream_width;
+ self.stream_height = fallback_stream_height;
+ self.stream_frames_per_second = preferred_frames_per_second;
+ const summary = self.fetchSubscriptionStatus(provider_url, bearer) catch |err| {
+ if (err == error.Cancelled) return err;
+ std.debug.print("GeForce NOW membership check unavailable: {s}\n", .{@errorName(err)});
+ return;
+ };
+ std.debug.print(
+ "GeForce NOW membership: {s}, state: {s}, gameplay allowed: {s}, entitled resolutions: {d}\n",
+ .{
+ summary.tier(),
+ summary.state(),
+ if (summary.gameplay_allowed) |allowed|
+ if (allowed) "yes" else "no"
+ else
+ "unknown",
+ summary.entitled_resolutions,
+ },
+ );
+ if (summary.bestForDisplayWithin(
+ display_width,
+ display_height,
+ preferred_frames_per_second,
+ maximum_stream_width,
+ maximum_stream_height,
+ )) |selected| {
+ self.stream_width = selected.width;
+ self.stream_height = selected.height;
+ self.stream_frames_per_second = selected.frames_per_second;
+ std.debug.print(
+ "GeForce NOW stream mode: {d}x{d}@{d} for {d}x{d} display\n",
+ .{
+ selected.width,
+ selected.height,
+ selected.frames_per_second,
+ display_width,
+ display_height,
+ },
+ );
+ }
+ }
+
+ fn fetchSubscriptionStatus(
+ self: *Client,
+ provider_url: []const u8,
+ bearer: []const u8,
+ ) !subscription.Summary {
+ const user_id = try self.auth.userId(self.allocator);
+ defer self.allocator.free(user_id);
+ if (!validQueryValue(user_id)) return error.InvalidUserId;
+
+ var server_info_url_buffer: [768]u8 = undefined;
+ const server_info_url = try std.fmt.bufPrintZ(
+ &server_info_url_buffer,
+ "{s}v2/serverInfo",
+ .{provider_url},
+ );
+ const server_info_response = try self.request("GET", server_info_url, null, bearer);
+ defer c.go_http_response_destroy(server_info_response);
+ const vpc_id = try protocol.parseVpcId(
+ self.allocator,
+ server_info_response.*.data[0..server_info_response.*.len],
+ );
+ defer self.allocator.free(vpc_id);
+ if (!validQueryValue(vpc_id)) return error.InvalidVpcId;
+
+ var url_buffer: [1024]u8 = undefined;
+ const url = try std.fmt.bufPrintZ(
+ &url_buffer,
+ "https://mes.geforcenow.com/v4/subscriptions?serviceName=gfn_pc&languageCode=en_US&vpcId={s}&userId={s}",
+ .{ vpc_id, user_id },
+ );
+ const authorization = try authorizationHeader(self.allocator, bearer);
+ defer secureFree(self.allocator, authorization);
+ var client_id_buffer: [256]u8 = undefined;
+ var headers = [_][*c]const u8{
+ authorization.ptr,
+ try header(&client_id_buffer, "nv-client-id", null, self.auth.protocolClientId()),
+ "Accept: application/json",
+ "nv-client-streamer: NVIDIA-CLASSIC",
+ "nv-client-type: NATIVE",
+ "nv-client-version: " ++ client_version,
+ "nv-device-os: LINUX",
+ "nv-device-type: DESKTOP",
+ "User-Agent: " ++ user_agent,
+ };
+ const response = c.go_http_request_bounded_cancelable(
+ "GET",
+ url.ptr,
+ null,
+ @ptrCast(&headers),
+ headers.len,
+ 1024 * 1024,
+ requestCancelled,
+ self,
+ );
+ defer c.go_http_response_destroy(response);
+ if (response == null and c.go_handheld_ui_cancelled(self.ui) != 0)
+ return error.Cancelled;
+ if (response == null or response.*.status < 200 or response.*.status >= 300 or
+ response.*.data == null)
+ {
+ const status = if (response) |value| value.*.status else 0;
+ std.debug.print("GeForce NOW membership check failed: HTTP {d}\n", .{status});
+ return error.SubscriptionRequestFailed;
+ }
+ return subscription.parse(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ );
+ }
+
+ fn resolveStreamingUrl(
+ self: *Client,
+ provider_url: []const u8,
+ bearer: []const u8,
+ ) ![]u8 {
+ var url_buffer: [768]u8 = undefined;
+ const url = try std.fmt.bufPrintZ(&url_buffer, "{s}v2/serverInfo", .{provider_url});
+ const authorization = try authorizationHeader(self.allocator, bearer);
+ defer secureFree(self.allocator, authorization);
+ var dynamic_headers: [2][256]u8 = undefined;
+ var headers = [_][*c]const u8{
+ authorization.ptr,
+ try header(&dynamic_headers[0], "nv-client-id", null, self.auth.protocolClientId()),
+ try header(&dynamic_headers[1], "x-device-id", null, self.auth.stableDeviceId()),
+ "Accept: application/json",
+ "nv-client-streamer: NVIDIA-CLASSIC",
+ "nv-client-type: NATIVE",
+ "nv-client-version: " ++ client_version,
+ "nv-device-os: LINUX",
+ "nv-device-type: DESKTOP",
+ "User-Agent: " ++ user_agent,
+ };
+ const response = c.go_http_request_bounded_cancelable(
+ "GET",
+ url.ptr,
+ null,
+ @ptrCast(&headers),
+ headers.len,
+ 1024 * 1024,
+ requestCancelled,
+ self,
+ );
+ defer c.go_http_response_destroy(response);
+ if (response == null and c.go_handheld_ui_cancelled(self.ui) != 0)
+ return error.Cancelled;
+ if (response == null or response.*.status < 200 or response.*.status >= 300 or
+ response.*.data == null)
+ return error.ServerInfoRequestFailed;
+ return (try protocol.parseLocalRegionUrl(
+ self.allocator,
+ response.*.data[0..response.*.len],
+ )) orelse self.allocator.dupe(u8, provider_url);
+ }
+
+ fn drawProgress(self: *Client, session: *const protocol.Session) void {
+ var detail_buffer: [96]u8 = undefined;
+ const detail = if (session.queue_position) |position|
+ std.fmt.bufPrintZ(&detail_buffer, "QUEUE POSITION {d}", .{position}) catch return
+ else switch (session.setup_step orelse 0) {
+ 1 => std.fmt.bufPrintZ(&detail_buffer, "WAITING FOR A STREAMING RIG", .{}) catch return,
+ 5 => std.fmt.bufPrintZ(&detail_buffer, "CLOSING THE PREVIOUS SESSION", .{}) catch return,
+ 6 => std.fmt.bufPrintZ(&detail_buffer, "PREPARING GAME STORAGE", .{}) catch return,
+ else => std.fmt.bufPrintZ(&detail_buffer, "PREPARING THE GAME", .{}) catch return,
+ };
+ c.go_handheld_ui_draw_loading(
+ self.ui,
+ "STARTING GEFORCE NOW",
+ detail.ptr,
+ c.GO_HANDHELD_UI_ACTION_CANCEL,
+ );
+ }
+};
+
+fn requestCancelled(context: ?*anyopaque) callconv(.c) c_int {
+ const client: *Client = @ptrCast(@alignCast(context orelse return 1));
+ if (client.ad_stop) |stop| return @intFromBool(stop.load(.acquire));
+ return c.go_handheld_ui_cancel_requested(client.ui);
+}
+
+fn retryableRequestFailure(err: anyerror, status: c_long, service_retryable: ?bool) bool {
+ if (err == error.HttpRequestFailed) return true;
+ if (err != error.HttpRequestRejected) return false;
+ if (service_retryable) |retryable| return retryable;
+ return switch (status) {
+ 408, 425, 429, 500, 502, 503, 504 => true,
+ else => false,
+ };
+}
+
+fn createRetryDelay(attempt: usize) c_uint {
+ return switch (attempt) {
+ 0 => 4000,
+ 1 => 8000,
+ else => 16000,
+ };
+}
+
+fn pollDelay(consecutive_errors: usize) c_uint {
+ if (consecutive_errors == 0) return 2000;
+ const shift: u4 = @intCast(@min(consecutive_errors - 1, 3));
+ return @min(@as(c_uint, 2000) << shift, 15000);
+}
+
+fn buildCreateSessionUrl(output: []u8, streaming_url: []const u8) ![:0]u8 {
+ return std.fmt.bufPrintZ(
+ output,
+ "{s}v2/session?keyboardLayout=en-US_qwerty&languageCode=en_US",
+ .{streaming_url},
+ );
+}
+
+fn header(
+ output: []u8,
+ name: []const u8,
+ scheme: ?[]const u8,
+ value: []const u8,
+) ![*c]const u8 {
+ const result = if (scheme) |prefix|
+ try std.fmt.bufPrintZ(output, "{s}: {s} {s}", .{ name, prefix, value })
+ else
+ try std.fmt.bufPrintZ(output, "{s}: {s}", .{ name, value });
+ return result.ptr;
+}
+
+fn authorizationHeader(allocator: std.mem.Allocator, bearer: []const u8) ![:0]u8 {
+ if (bearer.len == 0 or bearer.len > maximum_bearer_length)
+ return error.InvalidBearerToken;
+ return std.fmt.allocPrintZ(allocator, "Authorization: GFNJWT {s}", .{bearer});
+}
+
+fn secureFree(allocator: std.mem.Allocator, value: [:0]u8) void {
+ std.crypto.secureZero(u8, value);
+ allocator.free(value);
+}
+
+fn validQueryValue(value: []const u8) bool {
+ if (value.len == 0 or value.len > 128) return false;
+ for (value) |byte| {
+ if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_' and byte != '.')
+ return false;
+ }
+ return true;
+}
+
+test "session polling backs off after server failures" {
+ try std.testing.expectEqual(@as(c_uint, 2000), pollDelay(0));
+ try std.testing.expectEqual(@as(c_uint, 2000), pollDelay(1));
+ try std.testing.expectEqual(@as(c_uint, 4000), pollDelay(2));
+ try std.testing.expectEqual(@as(c_uint, 15000), pollDelay(9));
+}
+
+test "session requests retry only temporary transport and server failures" {
+ try std.testing.expect(retryableRequestFailure(error.HttpRequestFailed, 0, null));
+ try std.testing.expect(retryableRequestFailure(error.HttpRequestRejected, 429, null));
+ try std.testing.expect(retryableRequestFailure(error.HttpRequestRejected, 500, true));
+ try std.testing.expect(!retryableRequestFailure(error.HttpRequestRejected, 500, false));
+ try std.testing.expect(!retryableRequestFailure(error.HttpRequestRejected, 401, null));
+ try std.testing.expect(!retryableRequestFailure(error.RequestRejected, 200, null));
+ try std.testing.expect(!retryableRequestFailure(error.Cancelled, 0, null));
+}
+
+test "session creation retry delay is bounded" {
+ try std.testing.expectEqual(@as(c_uint, 4000), createRetryDelay(0));
+ try std.testing.expectEqual(@as(c_uint, 8000), createRetryDelay(1));
+ try std.testing.expectEqual(@as(c_uint, 16000), createRetryDelay(2));
+ try std.testing.expectEqual(@as(c_uint, 16000), createRetryDelay(9));
+}
+
+test "session creation uses the resolved streaming endpoint" {
+ var output: [256]u8 = undefined;
+ const url = try buildCreateSessionUrl(
+ &output,
+ "https://np-ams-01.cloudmatchbeta.nvidiagrid.net/",
+ );
+ try std.testing.expectEqualStrings(
+ "https://np-ams-01.cloudmatchbeta.nvidiagrid.net/v2/session?keyboardLayout=en-US_qwerty&languageCode=en_US",
+ url,
+ );
+}
+
+test "accepts only query-safe service identifiers" {
+ try std.testing.expect(validQueryValue("NP-AMS-08"));
+ try std.testing.expect(validQueryValue("12345678-1234-4abc-8def-123456789abc"));
+ try std.testing.expect(!validQueryValue("value&other=1"));
+}
+
+test "authorization header accepts realistic token lengths" {
+ const bearer = try std.testing.allocator.alloc(u8, 16 * 1024);
+ defer std.testing.allocator.free(bearer);
+ @memset(bearer, 'a');
+ const value = try authorizationHeader(std.testing.allocator, bearer);
+ defer secureFree(std.testing.allocator, value);
+ try std.testing.expect(std.mem.endsWith(u8, value, bearer));
+}
+
+test "queue polling preserves cancellation without retrying the request" {
+ const http = @import("gfn_http_fake");
+ http.reset();
+ var auth = http.authClient(auth_client.Client);
+ var client = Client{
+ .allocator = std.testing.allocator,
+ .auth = &auth,
+ .ui = @ptrCast(auth.ui),
+ .client_id = [_]u8{0} ** 37,
+ .streaming_url = @constCast("https://example.invalid/"),
+ .session = try protocol.parseSession(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":1}}
+ ),
+ };
+ defer client.session.?.deinit();
+ try std.testing.expectError(error.Cancelled, client.waitUntilReady());
+ try std.testing.expectEqual(@as(usize, 1), http.requests);
+ try std.testing.expectEqual(@as(usize, 1), http.polls);
+
+ http.reset();
+ try std.testing.expectError(error.Cancelled, client.selectStreamMode(
+ "https://example.invalid/",
+ auth.bearer().?,
+ 640,
+ 480,
+ ));
+ try std.testing.expectEqual(@as(usize, 1), http.requests);
+ try std.testing.expectEqual(@as(usize, 1), http.polls);
+}
+
+test "ended sessions stop polling and deletion accepts an empty success response" {
+ const http = @import("gfn_http_fake");
+ http.reset();
+ var auth = http.authClient(auth_client.Client);
+ var client = Client{
+ .allocator = std.testing.allocator,
+ .auth = &auth,
+ .ui = @ptrCast(auth.ui),
+ .client_id = [_]u8{0} ** 37,
+ .streaming_url = @constCast("https://example.invalid/"),
+ .session = try protocol.parseSession(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":4}}
+ ),
+ };
+ defer if (client.session) |*session| session.deinit();
+ try std.testing.expectError(error.SessionEnded, client.waitUntilReady());
+ try std.testing.expectEqual(@as(usize, 0), http.requests);
+
+ http.cancelled = true;
+ http.replyNoContent();
+ try client.stop();
+ try std.testing.expect(client.session == null);
+ try std.testing.expectEqual(@as(usize, 1), http.requests);
+ try std.testing.expectEqual(@as(usize, 0), http.polls);
+}
+
+test "membership and region discovery requests preserve cancellation" {
+ const http = @import("gfn_http_fake");
+ http.reset();
+ var auth = http.authClient(auth_client.Client);
+ var client = Client{
+ .allocator = std.testing.allocator,
+ .auth = &auth,
+ .ui = @ptrCast(auth.ui),
+ .client_id = [_]u8{0} ** 37,
+ };
+ http.cancel_on_poll = 2;
+ http.replyJson(
+ \\{"requestStatus":{"statusCode":1,"serverId":"TEST"}}
+ );
+ try std.testing.expectError(error.Cancelled, client.selectStreamMode(
+ "https://example.invalid/",
+ auth.bearer().?,
+ 640,
+ 480,
+ ));
+ try std.testing.expectEqual(@as(usize, 2), http.requests);
+ try std.testing.expectEqual(@as(usize, 2), http.polls);
+
+ http.reset();
+ try std.testing.expectError(error.Cancelled, client.resolveStreamingUrl(
+ "https://example.invalid/",
+ auth.bearer().?,
+ ));
+ try std.testing.expectEqual(@as(usize, 1), http.requests);
+ try std.testing.expectEqual(@as(usize, 1), http.polls);
+}
+
+test "ready sessions still require ad admission and repeated pod IDs play only once" {
+ const http = @import("gfn_http_fake");
+ http.reset();
+ http.cancel_on_poll = std.math.maxInt(usize);
+ const Handler = struct {
+ calls: usize = 0,
+ fn run(pointer: *anyopaque, client: *Client, opportunity: ads.Opportunity, provider: u8) !void {
+ const self: *@This() = @ptrCast(@alignCast(pointer));
+ self.calls += 1;
+ try std.testing.expectEqualStrings("pod", opportunity.id);
+ try std.testing.expectEqual(@as(u8, 1), provider);
+ http.replyJson("{\"requestStatus\":{\"statusCode\":1}}");
+ try client.reportAd(opportunity.id, .{ .action = .cancel, .reason = "VAST_EMPTY_RESPONSE" });
+ http.replyJson(
+ \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":2,"sessionAdsRequired":false,"sessionControlInfo":{"ip":"np.example.nvidiagrid.net","port":443}}}
+ );
+ }
+ };
+ var handler = Handler{};
+ var auth = http.authClient(auth_client.Client);
+ var client = Client{ .allocator = std.testing.allocator, .auth = &auth, .ui = @ptrCast(auth.ui), .client_id = [_]u8{0} ** 37, .streaming_url = @constCast("https://np.example.nvidiagrid.net/"), .ad_handler = .{ .context = &handler, .run = Handler.run }, .session = try protocol.parseSession(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":2,"sessionAdsRequired":true,"adProvider":1,"sessionControlInfo":{"ip":"np.example.nvidiagrid.net","port":443},"sessionAds":[{"adId":"pod","adUrl":"https://example.invalid/tag"},{"adId":"pod","adUrl":"https://example.invalid/tag"}]}}
+ ) };
+ defer client.session.?.deinit();
+ defer client.clearAds();
+ try client.waitUntilReady();
+ try std.testing.expectEqual(@as(usize, 1), handler.calls);
+ try std.testing.expectEqual(@as(usize, 2), http.requests);
+ try std.testing.expect(!client.session.?.ads.?.required);
+}
+
+test "ad reports require a positive CloudMatch acknowledgement" {
+ const http = @import("gfn_http_fake");
+ http.reset();
+ http.cancel_on_poll = std.math.maxInt(usize);
+ var auth = http.authClient(auth_client.Client);
+ var client = Client{ .allocator = std.testing.allocator, .auth = &auth, .ui = @ptrCast(auth.ui), .client_id = [_]u8{0} ** 37, .streaming_url = @constCast("https://np.example.nvidiagrid.net/"), .session = try protocol.parseSession(std.testing.allocator,
+ \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":1}}
+ ) };
+ defer client.session.?.deinit();
+ const update = ads.Update{ .action = .finish, .watched_ms = 5000, .duration_ms = 5000 };
+ for ([_][]const u8{ "{}", "{\"requestStatus\":{\"statusCode\":2}}", "{\"requestStatus\":{\"statusCode\":null}}" }) |body| {
+ http.replyJson(body);
+ try std.testing.expectError(error.AdUpdateRejected, client.reportAd("pod", update));
+ }
+ http.replyJson("{\"requestStatus\":{\"statusCode\":1}}");
+ try client.reportAd("pod", update);
+}
diff --git a/src/provider/geforce_now/signaling_client.zig b/src/provider/geforce_now/signaling_client.zig
new file mode 100644
index 0000000..e91abf2
--- /dev/null
+++ b/src/provider/geforce_now/signaling_client.zig
@@ -0,0 +1,218 @@
+const std = @import("std");
+const protocol = @import("signaling_protocol.zig");
+
+const c = @cImport({
+ @cInclude("websocket_client.h");
+});
+
+const origin = "https://play.geforcenow.com";
+const user_agent = "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36";
+const maximum_message_size = 128 * 1024;
+const maximum_send_attempts = 500;
+
+pub const Client = struct {
+ allocator: std.mem.Allocator,
+ socket: *c.GoWebSocket,
+ peer_name: []u8,
+ receive_buffer: std.ArrayList(u8),
+ local_peer_id: u32 = 0,
+ remote_peer_id: u32 = 1,
+ acknowledgement_counter: u32 = 0,
+ last_heartbeat_ms: i64,
+ receiving_fragmented_text: bool = false,
+
+ pub fn connect(
+ allocator: std.mem.Allocator,
+ signaling_url: []const u8,
+ session_id: []const u8,
+ peer_name: []const u8,
+ width: u16,
+ height: u16,
+ ) !*Client {
+ const url = try protocol.buildSignInUrl(allocator, signaling_url, session_id, peer_name);
+ defer allocator.free(url);
+ const subprotocol = try protocol.buildSubprotocol(allocator, session_id);
+ defer allocator.free(subprotocol);
+ const url_z = try allocator.dupeZ(u8, url);
+ defer allocator.free(url_z);
+ const subprotocol_z = try allocator.dupeZ(u8, subprotocol);
+ defer allocator.free(subprotocol_z);
+
+ var error_buffer: [256]u8 = [_]u8{0} ** 256;
+ const socket = c.go_websocket_open(
+ url_z.ptr,
+ origin,
+ subprotocol_z.ptr,
+ user_agent,
+ &error_buffer,
+ error_buffer.len,
+ ) orelse {
+ const message = std.mem.sliceTo(&error_buffer, 0);
+ if (message.len > 0) std.debug.print("GeForce NOW signaling: {s}\n", .{message});
+ return error.SignalingConnectionFailed;
+ };
+ errdefer c.go_websocket_close(socket);
+
+ const client = try allocator.create(Client);
+ errdefer allocator.destroy(client);
+ client.* = .{
+ .allocator = allocator,
+ .socket = socket,
+ .peer_name = try allocator.dupe(u8, peer_name),
+ .receive_buffer = std.ArrayList(u8).init(allocator),
+ .last_heartbeat_ms = std.time.milliTimestamp(),
+ };
+ errdefer allocator.free(client.peer_name);
+ try client.sendPeerInfo(width, height);
+ return client;
+ }
+
+ pub fn destroy(self: *Client) void {
+ c.go_websocket_close(self.socket);
+ self.receive_buffer.deinit();
+ self.allocator.free(self.peer_name);
+ const allocator = self.allocator;
+ allocator.destroy(self);
+ }
+
+ pub fn poll(self: *Client) !?protocol.DecodedMessage {
+ if (std.time.milliTimestamp() - self.last_heartbeat_ms >= 5000) {
+ const heartbeat = try protocol.encodeHeartbeat(self.allocator);
+ defer self.allocator.free(heartbeat);
+ try self.sendText(heartbeat);
+ self.last_heartbeat_ms = std.time.milliTimestamp();
+ }
+
+ var chunk: [4096]u8 = undefined;
+ var frame: c.GoWebSocketFrame = undefined;
+ while (true) {
+ const result = c.go_websocket_receive(self.socket, &chunk, chunk.len, &frame);
+ if (result == 0) return null;
+ if (result == -2) return error.SignalingClosed;
+ if (result < 0) {
+ std.debug.print("GeForce NOW signaling: {s}\n", .{
+ std.mem.span(c.go_websocket_last_error(self.socket)),
+ });
+ return error.SignalingReceiveFailed;
+ }
+ if (frame.flags & c.GO_WEBSOCKET_TEXT != 0 and
+ frame.offset == 0 and !self.receiving_fragmented_text)
+ self.receive_buffer.clearRetainingCapacity();
+ if (frame.flags & c.GO_WEBSOCKET_TEXT == 0) {
+ continue;
+ }
+ if (frame.length > maximum_message_size - self.receive_buffer.items.len)
+ return error.SignalingMessageTooLarge;
+ try self.receive_buffer.appendSlice(chunk[0..frame.length]);
+ if (frame.bytes_left != 0) continue;
+ if (frame.flags & c.GO_WEBSOCKET_CONTINUATION != 0) {
+ self.receiving_fragmented_text = true;
+ continue;
+ }
+ self.receiving_fragmented_text = false;
+
+ var message = try protocol.decode(self.allocator, self.receive_buffer.items);
+ errdefer message.deinit();
+ if (message.peer_info) |peer| {
+ if (peer.name) |name| {
+ if (std.mem.eql(u8, name, self.peer_name)) self.local_peer_id = peer.id;
+ }
+ }
+ if (message.peer_from) |from| self.remote_peer_id = from;
+ if (message.acknowledgement_id) |id| {
+ const own_echo = if (message.peer_info) |peer|
+ peer.id == self.local_peer_id
+ else
+ false;
+ if (!own_echo) {
+ const acknowledgement = try protocol.encodeAcknowledgement(self.allocator, id);
+ defer self.allocator.free(acknowledgement);
+ try self.sendText(acknowledgement);
+ }
+ }
+ if (message.heartbeat) {
+ const heartbeat = try protocol.encodeHeartbeat(self.allocator);
+ defer self.allocator.free(heartbeat);
+ try self.sendText(heartbeat);
+ self.last_heartbeat_ms = std.time.milliTimestamp();
+ }
+ return message;
+ }
+ }
+
+ pub fn sendAnswer(self: *Client, sdp: []const u8, nvst_sdp: []const u8) !void {
+ const message = try protocol.encodeAnswer(
+ self.allocator,
+ sdp,
+ nvst_sdp,
+ self.local_peer_id,
+ self.remote_peer_id,
+ self.nextAcknowledgement(),
+ );
+ defer self.allocator.free(message);
+ try self.sendText(message);
+ }
+
+ pub fn sendIceCandidate(
+ self: *Client,
+ candidate: []const u8,
+ sdp_mid: ?[]const u8,
+ ) !void {
+ if (protocol.isTcpIceCandidate(candidate)) return;
+ const message = try protocol.encodeIceCandidate(
+ self.allocator,
+ candidate,
+ sdp_mid,
+ null,
+ null,
+ self.local_peer_id,
+ self.remote_peer_id,
+ self.nextAcknowledgement(),
+ );
+ defer self.allocator.free(message);
+ try self.sendText(message);
+ }
+
+ fn sendPeerInfo(self: *Client, width: u16, height: u16) !void {
+ const message = try protocol.encodePeerInfo(
+ self.allocator,
+ self.peer_name,
+ self.local_peer_id,
+ self.nextAcknowledgement(),
+ width,
+ height,
+ );
+ defer self.allocator.free(message);
+ try self.sendText(message);
+ }
+
+ fn sendText(self: *Client, message: []const u8) !void {
+ var offset: usize = 0;
+ var attempts: usize = 0;
+ while (offset < message.len and attempts < maximum_send_attempts) : (attempts += 1) {
+ var sent: usize = 0;
+ const result = c.go_websocket_send(
+ self.socket,
+ message.ptr + offset,
+ message.len - offset,
+ c.GO_WEBSOCKET_TEXT,
+ &sent,
+ );
+ offset += sent;
+ if (result < 0) {
+ std.debug.print("GeForce NOW signaling: {s}\n", .{
+ std.mem.span(c.go_websocket_last_error(self.socket)),
+ });
+ return error.SignalingSendFailed;
+ }
+ if (offset < message.len) std.Thread.sleep(std.time.ns_per_ms);
+ }
+ if (offset != message.len) return error.SignalingSendTimeout;
+ }
+
+ fn nextAcknowledgement(self: *Client) u32 {
+ self.acknowledgement_counter +%= 1;
+ if (self.acknowledgement_counter == 0) self.acknowledgement_counter = 1;
+ return self.acknowledgement_counter;
+ }
+};
diff --git a/src/provider/geforce_now/signaling_protocol.zig b/src/provider/geforce_now/signaling_protocol.zig
new file mode 100644
index 0000000..f95314e
--- /dev/null
+++ b/src/provider/geforce_now/signaling_protocol.zig
@@ -0,0 +1,459 @@
+const std = @import("std");
+
+pub const PeerInfo = struct {
+ id: u32,
+ name: ?[]u8,
+};
+
+pub const IceCandidate = struct {
+ candidate: []u8,
+ sdp_mid: ?[]u8,
+ sdp_m_line_index: ?u32,
+ username_fragment: ?[]u8,
+};
+
+pub const Payload = union(enum) {
+ none,
+ bye,
+ offer: []u8,
+ ice: IceCandidate,
+ unknown: ?[]u8,
+};
+
+pub const DecodedMessage = struct {
+ allocator: std.mem.Allocator,
+ peer_info: ?PeerInfo = null,
+ acknowledgement_id: ?u32 = null,
+ heartbeat: bool = false,
+ peer_removed: bool = false,
+ peer_from: ?u32 = null,
+ payload: Payload = .none,
+
+ pub fn deinit(self: *DecodedMessage) void {
+ if (self.peer_info) |peer| {
+ if (peer.name) |name| self.allocator.free(name);
+ }
+ switch (self.payload) {
+ .offer => |sdp| self.allocator.free(sdp),
+ .ice => |candidate| {
+ self.allocator.free(candidate.candidate);
+ if (candidate.sdp_mid) |mid| self.allocator.free(mid);
+ if (candidate.username_fragment) |fragment| self.allocator.free(fragment);
+ },
+ .unknown => |message_type| {
+ if (message_type) |value| self.allocator.free(value);
+ },
+ .none, .bye => {},
+ }
+ self.* = undefined;
+ }
+};
+
+pub fn buildSignInUrl(
+ allocator: std.mem.Allocator,
+ signaling_url: []const u8,
+ session_id: []const u8,
+ peer_name: []const u8,
+) ![]u8 {
+ const trimmed = std.mem.trim(u8, signaling_url, " \t\r\n");
+ if (trimmed.len == 0 or session_id.len == 0 or peer_name.len == 0) return error.InvalidUrl;
+
+ const query_index = std.mem.indexOfAny(u8, trimmed, "?#") orelse trimmed.len;
+ const without_query = trimmed[0..query_index];
+ var base = without_query;
+ const scheme: []const u8 = "wss://";
+ if (std.mem.startsWith(u8, base, "https://")) {
+ base = base["https://".len..];
+ } else if (std.mem.startsWith(u8, base, "wss://")) {
+ base = base["wss://".len..];
+ } else if (std.mem.startsWith(u8, base, "http://") or
+ std.mem.startsWith(u8, base, "ws://"))
+ {
+ return error.InsecureUrl;
+ }
+
+ base = std.mem.trimRight(u8, base, "/");
+ if (std.mem.endsWith(u8, base, "/sign_in"))
+ base = std.mem.trimRight(u8, base[0 .. base.len - "/sign_in".len], "/");
+ if (base.len == 0) return error.InvalidUrl;
+
+ var result = std.ArrayList(u8).init(allocator);
+ errdefer result.deinit();
+ const writer = result.writer();
+ try writer.print("{s}{s}/sign_in?peer_id=", .{ scheme, base });
+ try writeQueryValue(writer, peer_name);
+ try writer.writeAll("&version=2&peer_role=1&pairing_id=");
+ try writeQueryValue(writer, session_id);
+
+ const url = try result.toOwnedSlice();
+ errdefer allocator.free(url);
+ const parsed = std.Uri.parse(url) catch return error.InvalidUrl;
+ if (parsed.host == null or parsed.user != null or parsed.password != null)
+ return error.InvalidUrl;
+ return url;
+}
+
+pub fn buildSubprotocol(allocator: std.mem.Allocator, session_id: []const u8) ![]u8 {
+ if (session_id.len == 0) return error.InvalidSessionId;
+ for (session_id) |byte| {
+ if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_' and byte != '.')
+ return error.InvalidSessionId;
+ }
+ return std.fmt.allocPrint(allocator, "x-nv-sessionid.{s}", .{session_id});
+}
+
+pub fn decode(allocator: std.mem.Allocator, text: []const u8) !DecodedMessage {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{});
+ defer parsed.deinit();
+ const root = try objectValue(parsed.value);
+
+ var message = DecodedMessage{ .allocator = allocator };
+ errdefer message.deinit();
+
+ if (root.get("peer_info")) |raw_peer_info| {
+ const peer_object = try objectValue(raw_peer_info);
+ message.peer_info = .{
+ .id = try requiredUnsigned(peer_object, "id"),
+ .name = try duplicateOptionalString(allocator, peer_object, "name"),
+ };
+ }
+ message.acknowledgement_id = try optionalUnsigned(root, "ackid");
+ message.heartbeat = root.get("hb") != null;
+ if (try optionalString(root, "error")) |error_name|
+ message.peer_removed = std.mem.eql(u8, error_name, "peerRemoved");
+
+ const raw_peer_message = root.get("peer_msg") orelse return message;
+ const peer_message = try objectValue(raw_peer_message);
+ message.peer_from = try optionalUnsigned(peer_message, "from");
+ const nested_text = try requiredString(peer_message, "msg");
+ const trimmed = std.mem.trim(u8, nested_text, " \t\r\n");
+ if (std.mem.eql(u8, trimmed, "BYE")) {
+ message.payload = .bye;
+ return message;
+ }
+
+ const nested = std.json.parseFromSlice(std.json.Value, allocator, trimmed, .{}) catch
+ return error.InvalidPeerPayload;
+ defer nested.deinit();
+ const payload = objectValue(nested.value) catch return error.InvalidPeerPayload;
+ const message_type = try optionalString(payload, "type");
+ if (message_type != null and std.mem.eql(u8, message_type.?, "offer")) {
+ message.payload = .{ .offer = try duplicateRequiredString(allocator, payload, "sdp") };
+ return message;
+ }
+ if (payload.get("candidate") != null) {
+ const candidate = try duplicateRequiredString(allocator, payload, "candidate");
+ errdefer allocator.free(candidate);
+ const sdp_mid = try duplicateOptionalString(allocator, payload, "sdpMid");
+ errdefer if (sdp_mid) |mid| allocator.free(mid);
+ const username_fragment = try duplicateOptionalString(
+ allocator,
+ payload,
+ "usernameFragment",
+ );
+ errdefer if (username_fragment) |fragment| allocator.free(fragment);
+ message.payload = .{ .ice = .{
+ .candidate = candidate,
+ .sdp_mid = sdp_mid,
+ .sdp_m_line_index = try optionalUnsigned(payload, "sdpMLineIndex"),
+ .username_fragment = username_fragment,
+ } };
+ return message;
+ }
+
+ message.payload = .{ .unknown = if (message_type) |value|
+ try allocator.dupe(u8, value)
+ else
+ null };
+ return message;
+}
+
+pub fn encodeHeartbeat(allocator: std.mem.Allocator) ![]u8 {
+ return std.json.stringifyAlloc(allocator, .{ .hb = 1 }, .{});
+}
+
+pub fn encodeAcknowledgement(allocator: std.mem.Allocator, id: u32) ![]u8 {
+ return std.json.stringifyAlloc(allocator, .{ .ack = id }, .{});
+}
+
+pub fn encodePeerInfo(
+ allocator: std.mem.Allocator,
+ peer_name: []const u8,
+ peer_id: u32,
+ acknowledgement_id: u32,
+ width: u16,
+ height: u16,
+) ![]u8 {
+ const resolution = try std.fmt.allocPrint(allocator, "{d}x{d}", .{ width, height });
+ defer allocator.free(resolution);
+ return std.json.stringifyAlloc(allocator, .{
+ .ackid = acknowledgement_id,
+ .peer_info = .{
+ .browser = "Chrome",
+ .browserVersion = "131",
+ .connected = true,
+ .id = peer_id,
+ .name = peer_name,
+ .peerRole = 0,
+ .resolution = resolution,
+ .version = 2,
+ },
+ }, .{});
+}
+
+pub fn encodeAnswer(
+ allocator: std.mem.Allocator,
+ sdp: []const u8,
+ nvst_sdp: ?[]const u8,
+ from: u32,
+ to: u32,
+ acknowledgement_id: u32,
+) ![]u8 {
+ if (nvst_sdp) |nvst| {
+ return encodePeerPayload(allocator, .{
+ .type = "answer",
+ .sdp = sdp,
+ .nvstSdp = nvst,
+ }, from, to, acknowledgement_id);
+ }
+ return encodePeerPayload(allocator, .{
+ .type = "answer",
+ .sdp = sdp,
+ }, from, to, acknowledgement_id);
+}
+
+pub fn encodeIceCandidate(
+ allocator: std.mem.Allocator,
+ candidate: []const u8,
+ sdp_mid: ?[]const u8,
+ sdp_m_line_index: ?u32,
+ username_fragment: ?[]const u8,
+ from: u32,
+ to: u32,
+ acknowledgement_id: u32,
+) ![]u8 {
+ var object = std.json.ObjectMap.init(allocator);
+ defer object.deinit();
+ try object.put("candidate", .{ .string = candidate });
+ if (sdp_mid) |value| try object.put("sdpMid", .{ .string = value });
+ if (sdp_m_line_index) |value| try object.put("sdpMLineIndex", .{ .integer = value });
+ if (username_fragment) |value|
+ try object.put("usernameFragment", .{ .string = value });
+ return encodePeerPayload(
+ allocator,
+ std.json.Value{ .object = object },
+ from,
+ to,
+ acknowledgement_id,
+ );
+}
+
+pub fn isTcpIceCandidate(candidate: []const u8) bool {
+ var tokens = std.mem.tokenizeAny(u8, candidate, " \t\r\n");
+ _ = tokens.next() orelse return false;
+ _ = tokens.next() orelse return false;
+ const transport = tokens.next() orelse return false;
+ return std.ascii.eqlIgnoreCase(transport, "tcp");
+}
+
+fn encodePeerPayload(
+ allocator: std.mem.Allocator,
+ payload: anytype,
+ from: u32,
+ to: u32,
+ acknowledgement_id: u32,
+) ![]u8 {
+ const payload_text = try std.json.stringifyAlloc(allocator, payload, .{});
+ defer allocator.free(payload_text);
+ return std.json.stringifyAlloc(allocator, .{
+ .peer_msg = .{
+ .from = from,
+ .to = to,
+ .msg = payload_text,
+ },
+ .ackid = acknowledgement_id,
+ }, .{});
+}
+
+fn writeQueryValue(writer: anytype, value: []const u8) !void {
+ const hex = "0123456789ABCDEF";
+ for (value) |byte| {
+ if (std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '.' or byte == '_' or byte == '~') {
+ try writer.writeByte(byte);
+ } else {
+ try writer.writeByte('%');
+ try writer.writeByte(hex[byte >> 4]);
+ try writer.writeByte(hex[byte & 0x0f]);
+ }
+ }
+}
+
+fn objectValue(value: std.json.Value) !std.json.ObjectMap {
+ return switch (value) {
+ .object => |object| object,
+ else => error.ExpectedObject,
+ };
+}
+
+fn requiredString(object: std.json.ObjectMap, name: []const u8) ![]const u8 {
+ return (try optionalString(object, name)) orelse error.MissingField;
+}
+
+fn optionalString(object: std.json.ObjectMap, name: []const u8) !?[]const u8 {
+ const value = object.get(name) orelse return null;
+ return switch (value) {
+ .string => |text| text,
+ .null => null,
+ else => error.InvalidField,
+ };
+}
+
+fn duplicateRequiredString(
+ allocator: std.mem.Allocator,
+ object: std.json.ObjectMap,
+ name: []const u8,
+) ![]u8 {
+ return allocator.dupe(u8, try requiredString(object, name));
+}
+
+fn duplicateOptionalString(
+ allocator: std.mem.Allocator,
+ object: std.json.ObjectMap,
+ name: []const u8,
+) !?[]u8 {
+ if (try optionalString(object, name)) |value| return try allocator.dupe(u8, value);
+ return null;
+}
+
+fn requiredUnsigned(object: std.json.ObjectMap, name: []const u8) !u32 {
+ return (try optionalUnsigned(object, name)) orelse error.MissingField;
+}
+
+fn optionalUnsigned(object: std.json.ObjectMap, name: []const u8) !?u32 {
+ const value = object.get(name) orelse return null;
+ return switch (value) {
+ .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32))
+ @intCast(number)
+ else
+ error.InvalidField,
+ .null => null,
+ else => error.InvalidField,
+ };
+}
+
+test "normalizes the sign-in URL and escapes identifiers" {
+ const url = try buildSignInUrl(
+ std.testing.allocator,
+ "https://example.test/nvst/sign_in?old=1",
+ "session/one",
+ "peer name",
+ );
+ defer std.testing.allocator.free(url);
+ try std.testing.expectEqualStrings(
+ "wss://example.test/nvst/sign_in?peer_id=peer%20name&version=2&peer_role=1&pairing_id=session%2Fone",
+ url,
+ );
+
+ const protocol = try buildSubprotocol(std.testing.allocator, "session-one");
+ defer std.testing.allocator.free(protocol);
+ try std.testing.expectEqualStrings("x-nv-sessionid.session-one", protocol);
+ try std.testing.expectError(
+ error.InvalidSessionId,
+ buildSubprotocol(std.testing.allocator, "session one"),
+ );
+ try std.testing.expectError(
+ error.InsecureUrl,
+ buildSignInUrl(std.testing.allocator, "http://example.test/nvst", "session", "peer"),
+ );
+ try std.testing.expectError(
+ error.InvalidUrl,
+ buildSignInUrl(std.testing.allocator, "https://user@example.test/nvst", "session", "peer"),
+ );
+}
+
+test "decodes offer and ICE peer messages" {
+ var offer = try decode(std.testing.allocator,
+ \\{"peer_msg":{"from":7,"to":2,"msg":"{\"type\":\"offer\",\"sdp\":\"v=0\\r\\n\"}"}}
+ );
+ defer offer.deinit();
+ try std.testing.expectEqual(@as(?u32, 7), offer.peer_from);
+ try std.testing.expectEqualStrings("v=0\r\n", offer.payload.offer);
+
+ var ice = try decode(std.testing.allocator,
+ \\{"peer_msg":{"from":7,"msg":"{\"candidate\":\"candidate:1 1 UDP 1 192.0.2.1 10000 typ host\",\"sdpMid\":\"video\",\"sdpMLineIndex\":2}"}}
+ );
+ defer ice.deinit();
+ try std.testing.expectEqualStrings("video", ice.payload.ice.sdp_mid.?);
+ try std.testing.expectEqual(@as(?u32, 2), ice.payload.ice.sdp_m_line_index);
+}
+
+test "decodes envelope metadata and disconnect messages" {
+ var metadata = try decode(std.testing.allocator,
+ \\{"peer_info":{"id":9,"name":"peer-nine"},"ackid":41,"hb":1}
+ );
+ defer metadata.deinit();
+ try std.testing.expectEqual(@as(u32, 9), metadata.peer_info.?.id);
+ try std.testing.expectEqualStrings("peer-nine", metadata.peer_info.?.name.?);
+ try std.testing.expectEqual(@as(?u32, 41), metadata.acknowledgement_id);
+ try std.testing.expect(metadata.heartbeat);
+
+ var bye = try decode(std.testing.allocator, "{\"peer_msg\":{\"msg\":\"BYE\"}}");
+ defer bye.deinit();
+ try std.testing.expect(bye.payload == .bye);
+
+ var removed = try decode(std.testing.allocator, "{\"error\":\"peerRemoved\"}");
+ defer removed.deinit();
+ try std.testing.expect(removed.peer_removed);
+}
+
+test "encodes answer and acknowledgement envelopes" {
+ const peer_info = try encodePeerInfo(std.testing.allocator, "peer-one", 0, 1, 1280, 720);
+ defer std.testing.allocator.free(peer_info);
+ try std.testing.expect(std.mem.indexOf(u8, peer_info, "\"peerRole\":0") != null);
+
+ const answer = try encodeAnswer(
+ std.testing.allocator,
+ "v=0\r\n",
+ "nvst",
+ 2,
+ 1,
+ 8,
+ );
+ defer std.testing.allocator.free(answer);
+
+ var decoded = try decode(std.testing.allocator, answer);
+ defer decoded.deinit();
+ try std.testing.expectEqual(@as(?u32, 2), decoded.peer_from);
+ try std.testing.expectEqual(@as(?u32, 8), decoded.acknowledgement_id);
+ try std.testing.expect(decoded.payload == .unknown);
+
+ const acknowledgement = try encodeAcknowledgement(std.testing.allocator, 41);
+ defer std.testing.allocator.free(acknowledgement);
+ try std.testing.expectEqualStrings("{\"ack\":41}", acknowledgement);
+}
+
+test "recognizes only TCP ICE transport tokens" {
+ try std.testing.expect(isTcpIceCandidate(
+ "candidate:1 1 TCP 1 192.0.2.1 9 typ host tcptype active",
+ ));
+ try std.testing.expect(!isTcpIceCandidate(
+ "candidate:2 1 udp 1 192.0.2.1 10000 typ host",
+ ));
+ try std.testing.expect(!isTcpIceCandidate("tcp-but-not-a-candidate"));
+}
+
+test "rejects malformed and incomplete peer messages" {
+ try std.testing.expectError(error.UnexpectedEndOfInput, decode(std.testing.allocator, "{"));
+ try std.testing.expectError(
+ error.MissingField,
+ decode(std.testing.allocator, "{\"peer_info\":{}}"),
+ );
+ try std.testing.expectError(
+ error.MissingField,
+ decode(std.testing.allocator, "{\"peer_msg\":{}}"),
+ );
+ try std.testing.expectError(
+ error.InvalidPeerPayload,
+ decode(std.testing.allocator, "{\"peer_msg\":{\"msg\":\"{\"}}"),
+ );
+}
diff --git a/src/provider/geforce_now/subscription_protocol.zig b/src/provider/geforce_now/subscription_protocol.zig
new file mode 100644
index 0000000..3f5aa46
--- /dev/null
+++ b/src/provider/geforce_now/subscription_protocol.zig
@@ -0,0 +1,289 @@
+const std = @import("std");
+
+pub const maximum_resolutions = 64;
+
+pub const Resolution = struct {
+ width: u16,
+ height: u16,
+ frames_per_second: u16,
+};
+
+pub const Summary = struct {
+ tier_buffer: [32]u8,
+ tier_length: usize,
+ state_buffer: [32]u8,
+ state_length: usize,
+ gameplay_allowed: ?bool,
+ entitled_resolutions: usize,
+ resolutions: [maximum_resolutions]Resolution,
+ resolution_count: usize,
+
+ pub fn tier(self: *const Summary) []const u8 {
+ return self.tier_buffer[0..self.tier_length];
+ }
+
+ pub fn state(self: *const Summary) []const u8 {
+ return self.state_buffer[0..self.state_length];
+ }
+
+ pub fn bestForDisplayWithin(
+ self: *const Summary,
+ display_width: u16,
+ display_height: u16,
+ preferred_frames_per_second: u16,
+ maximum_width: u16,
+ maximum_height: u16,
+ ) ?Resolution {
+ if (display_width == 0 or display_height == 0 or self.resolution_count == 0)
+ return null;
+
+ var result: ?Resolution = null;
+ for (self.resolutions[0..self.resolution_count]) |resolution| {
+ if (resolution.width > maximum_width or resolution.height > maximum_height) continue;
+ if (resolution.frames_per_second != preferred_frames_per_second) continue;
+ if (betterResolution(resolution, result, display_width, display_height))
+ result = resolution;
+ }
+ if (result != null) return result;
+
+ var fallback_frames_per_second: ?u16 = null;
+ for (self.resolutions[0..self.resolution_count]) |resolution| {
+ if (resolution.width > maximum_width or resolution.height > maximum_height) continue;
+ if (fallback_frames_per_second == null or
+ resolution.frames_per_second < fallback_frames_per_second.?)
+ fallback_frames_per_second = resolution.frames_per_second;
+ }
+ const fallback_rate = fallback_frames_per_second orelse return null;
+ for (self.resolutions[0..self.resolution_count]) |resolution| {
+ if (resolution.width > maximum_width or resolution.height > maximum_height) continue;
+ if (resolution.frames_per_second != fallback_rate) continue;
+ if (betterResolution(resolution, result, display_width, display_height))
+ result = resolution;
+ }
+ return result;
+ }
+};
+
+fn betterResolution(
+ candidate: Resolution,
+ current: ?Resolution,
+ display_width: u16,
+ display_height: u16,
+) bool {
+ const previous = current orelse return true;
+ const candidate_fits = candidate.width >= display_width and candidate.height >= display_height;
+ const previous_fits = previous.width >= display_width and previous.height >= display_height;
+ if (candidate_fits != previous_fits) return candidate_fits;
+
+ const candidate_error = aspectError(candidate, display_width, display_height);
+ const previous_error = aspectError(previous, display_width, display_height);
+ const candidate_scaled_error = candidate_error * previous.height;
+ const previous_scaled_error = previous_error * candidate.height;
+ if (candidate_scaled_error != previous_scaled_error)
+ return candidate_scaled_error < previous_scaled_error;
+
+ const candidate_pixels = @as(u32, candidate.width) * candidate.height;
+ const previous_pixels = @as(u32, previous.width) * previous.height;
+ if (candidate_pixels != previous_pixels)
+ return if (candidate_fits)
+ candidate_pixels < previous_pixels
+ else
+ candidate_pixels > previous_pixels;
+ return candidate.frames_per_second < previous.frames_per_second;
+}
+
+fn aspectError(resolution: Resolution, display_width: u16, display_height: u16) u64 {
+ const scaled_width = @as(u64, resolution.width) * display_height;
+ const scaled_height = @as(u64, display_width) * resolution.height;
+ return if (scaled_width >= scaled_height)
+ scaled_width - scaled_height
+ else
+ scaled_height - scaled_width;
+}
+
+pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Summary {
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
+ defer parsed.deinit();
+ const root = try object(parsed.value);
+ var entitled: usize = 0;
+ var entitled_modes = [_]Resolution{.{
+ .width = 0,
+ .height = 0,
+ .frames_per_second = 0,
+ }} ** maximum_resolutions;
+ var resolution_count: usize = 0;
+ if (optionalObject(root, "features")) |features| {
+ if (optionalArray(features, "resolutions")) |resolutions| {
+ for (resolutions.items) |value| {
+ const resolution = object(value) catch continue;
+ if (!(optionalBool(resolution, "isEntitled") orelse false)) continue;
+ entitled += 1;
+ if (resolution_count >= maximum_resolutions) continue;
+ const width = optionalUnsigned(resolution, "widthInPixels") orelse continue;
+ const height = optionalUnsigned(resolution, "heightInPixels") orelse continue;
+ const frames_per_second = optionalUnsigned(resolution, "framesPerSecond") orelse continue;
+ if (width == 0 or width > std.math.maxInt(u16) or
+ height == 0 or height > std.math.maxInt(u16) or
+ frames_per_second == 0 or frames_per_second > std.math.maxInt(u16))
+ continue;
+ entitled_modes[resolution_count] = .{
+ .width = @intCast(width),
+ .height = @intCast(height),
+ .frames_per_second = @intCast(frames_per_second),
+ };
+ resolution_count += 1;
+ }
+ }
+ }
+ const current_state = optionalObject(root, "currentSubscriptionState");
+ const gameplay_allowed = if (current_state) |state| optionalBool(state, "isGamePlayAllowed") else null;
+ const state = if (current_state) |value| optionalString(value, "state") orelse "UNKNOWN" else "UNKNOWN";
+ const tier = optionalString(root, "membershipTier") orelse "UNKNOWN";
+ var summary = Summary{
+ .tier_buffer = [_]u8{0} ** 32,
+ .tier_length = @min(tier.len, 32),
+ .state_buffer = [_]u8{0} ** 32,
+ .state_length = @min(state.len, 32),
+ .gameplay_allowed = gameplay_allowed,
+ .entitled_resolutions = entitled,
+ .resolutions = entitled_modes,
+ .resolution_count = resolution_count,
+ };
+ @memcpy(summary.tier_buffer[0..summary.tier_length], tier[0..summary.tier_length]);
+ @memcpy(summary.state_buffer[0..summary.state_length], state[0..summary.state_length]);
+ return summary;
+}
+
+fn object(value: std.json.Value) !std.json.ObjectMap {
+ return switch (value) {
+ .object => |result| result,
+ else => error.ExpectedObject,
+ };
+}
+
+fn optionalString(value: std.json.ObjectMap, key: []const u8) ?[]const u8 {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .string => |text| if (text.len > 0) text else null,
+ else => null,
+ };
+}
+
+fn optionalObject(value: std.json.ObjectMap, key: []const u8) ?std.json.ObjectMap {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .object => |result| result,
+ else => null,
+ };
+}
+
+fn optionalArray(value: std.json.ObjectMap, key: []const u8) ?std.json.Array {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .array => |result| result,
+ else => null,
+ };
+}
+
+fn optionalBool(value: std.json.ObjectMap, key: []const u8) ?bool {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .bool => |result| result,
+ else => null,
+ };
+}
+
+fn optionalUnsigned(value: std.json.ObjectMap, key: []const u8) ?u64 {
+ const field = value.get(key) orelse return null;
+ return switch (field) {
+ .integer => |result| if (result >= 0) @intCast(result) else null,
+ else => null,
+ };
+}
+
+test "summarizes membership state without retaining account data" {
+ const summary = try parse(std.testing.allocator,
+ \\{
+ \\ "membershipTier":"FREE",
+ \\ "currentSubscriptionState":{"state":"ACTIVE","isGamePlayAllowed":true},
+ \\ "features":{"resolutions":[
+ \\ {"widthInPixels":1280,"heightInPixels":720,"framesPerSecond":60,"isEntitled":true},
+ \\ {"widthInPixels":1920,"heightInPixels":1080,"framesPerSecond":60,"isEntitled":false}
+ \\ ]}
+ \\}
+ );
+ try std.testing.expectEqualStrings("FREE", summary.tier());
+ try std.testing.expectEqualStrings("ACTIVE", summary.state());
+ try std.testing.expectEqual(@as(?bool, true), summary.gameplay_allowed);
+ try std.testing.expectEqual(@as(usize, 1), summary.entitled_resolutions);
+ try std.testing.expectEqualSlices(Resolution, &.{.{
+ .width = 1280,
+ .height = 720,
+ .frames_per_second = 60,
+ }}, summary.resolutions[0..summary.resolution_count]);
+}
+
+test "accepts subscriptions without optional state details" {
+ const summary = try parse(std.testing.allocator, "{\"membershipTier\":\"FREE\"}");
+ try std.testing.expectEqualStrings("FREE", summary.tier());
+ try std.testing.expectEqualStrings("UNKNOWN", summary.state());
+ try std.testing.expectEqual(@as(?bool, null), summary.gameplay_allowed);
+ try std.testing.expectEqual(@as(usize, 0), summary.entitled_resolutions);
+ try std.testing.expectEqual(@as(usize, 0), summary.resolution_count);
+}
+
+test "selects the lowest 30 fps mode matching the display" {
+ var summary = try parse(std.testing.allocator,
+ \\{
+ \\ "features":{"resolutions":[
+ \\ {"widthInPixels":1280,"heightInPixels":720,"framesPerSecond":30,"isEntitled":true},
+ \\ {"widthInPixels":1920,"heightInPixels":1080,"framesPerSecond":30,"isEntitled":true},
+ \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":30,"isEntitled":true},
+ \\ {"widthInPixels":800,"heightInPixels":600,"framesPerSecond":60,"isEntitled":true}
+ \\ ]}
+ \\}
+ );
+
+ try std.testing.expectEqual(Resolution{
+ .width = 1024,
+ .height = 768,
+ .frames_per_second = 30,
+ }, summary.bestForDisplayWithin(640, 480, 30, 1280, 768).?);
+ try std.testing.expectEqual(Resolution{
+ .width = 1280,
+ .height = 720,
+ .frames_per_second = 30,
+ }, summary.bestForDisplayWithin(640, 360, 30, 1280, 768).?);
+}
+
+test "uses another frame rate only when the preferred rate is unavailable" {
+ var summary = try parse(std.testing.allocator,
+ \\{
+ \\ "features":{"resolutions":[
+ \\ {"widthInPixels":800,"heightInPixels":600,"framesPerSecond":120,"isEntitled":true},
+ \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":60,"isEntitled":true}
+ \\ ]}
+ \\}
+ );
+
+ try std.testing.expectEqual(Resolution{
+ .width = 1024,
+ .height = 768,
+ .frames_per_second = 60,
+ }, summary.bestForDisplayWithin(640, 480, 30, 1280, 768).?);
+}
+
+test "does not select a mode larger than the decoder supports" {
+ var summary = try parse(std.testing.allocator,
+ \\{
+ \\ "features":{"resolutions":[
+ \\ {"widthInPixels":1920,"heightInPixels":1080,"framesPerSecond":30,"isEntitled":true}
+ \\ ]}
+ \\}
+ );
+
+ try std.testing.expectEqual(
+ @as(?Resolution, null),
+ summary.bestForDisplayWithin(640, 360, 30, 1280, 768),
+ );
+}
diff --git a/src/provider/geforce_now/vast.zig b/src/provider/geforce_now/vast.zig
new file mode 100644
index 0000000..185bfe4
--- /dev/null
+++ b/src/provider/geforce_now/vast.zig
@@ -0,0 +1,355 @@
+const std = @import("std");
+const xml = @import("xml_document");
+
+pub const Track = struct {
+ event: []const u8,
+ url: []const u8,
+ offset_ms: ?u64 = null,
+ offset_percent: ?u8 = null,
+};
+pub const Position = union(enum) { start, end, pixel: u16 };
+pub const Media = struct { url: []const u8, width: u16, height: u16, bitrate: u32 };
+pub const max_fallback_width = 1920;
+pub const max_fallback_height = 1080;
+pub const Icon = struct {
+ program: []const u8,
+ image: []const u8,
+ fallback: ?[]const u8,
+ click_through: ?[]const u8,
+ views: []const []const u8,
+ clicks: []const []const u8,
+ offset_ms: u64,
+ duration_ms: ?u64,
+ width: u16,
+ height: u16,
+ x: Position,
+ y: Position,
+ fallback_width: u16 = 0,
+ fallback_height: u16 = 0,
+};
+pub const Creative = struct {
+ duration_ms: u64,
+ media: []const Media,
+ tracks: []const Track,
+ icons: []const Icon,
+};
+pub const Ad = struct {
+ sequence: ?u32,
+ impressions: []const []const u8,
+ errors: []const []const u8,
+ tracks: []const Track = &.{},
+ icons: []const Icon = &.{},
+ verification_errors: []const []const u8 = &.{},
+ body: union(enum) { inline_ad: Creative, wrapper: Wrapper },
+};
+pub const Wrapper = struct {
+ url: []const u8,
+ follow_wrappers: bool,
+ allow_multiple: bool,
+ fallback_on_no_ad: bool,
+};
+pub const Manifest = struct {
+ document: xml.Document,
+ ads: []const Ad,
+ errors: []const []const u8,
+
+ pub fn deinit(self: *Manifest) void {
+ self.document.deinit();
+ }
+};
+
+pub fn duration(input: []const u8) !u64 {
+ var fields = std.mem.splitScalar(u8, input, ':');
+ const hours = std.fmt.parseUnsigned(u64, fields.next() orelse return error.InvalidDuration, 10) catch return error.InvalidDuration;
+ const minutes = std.fmt.parseUnsigned(u64, fields.next() orelse return error.InvalidDuration, 10) catch return error.InvalidDuration;
+ var seconds = std.mem.splitScalar(u8, fields.next() orelse return error.InvalidDuration, '.');
+ const whole = std.fmt.parseUnsigned(u64, seconds.next().?, 10) catch return error.InvalidDuration;
+ if (minutes >= 60 or whole >= 60 or hours > 1 or fields.next() != null) return error.InvalidDuration;
+ var fraction: u64 = 0;
+ if (seconds.next()) |decimal| {
+ if (decimal.len == 0 or decimal.len > 3 or seconds.next() != null) return error.InvalidDuration;
+ fraction = std.fmt.parseUnsigned(u64, decimal, 10) catch return error.InvalidDuration;
+ for (decimal.len..3) |_| fraction *= 10;
+ }
+ return (hours * 3600 + minutes * 60 + whole) * 1000 + fraction;
+}
+
+pub fn validateUrl(address: []const u8) !void {
+ if (address.len == 0 or address.len > 8192) return error.InvalidAdUrl;
+ for (address) |byte| if (byte < 0x20 or byte == 0x7f) return error.InvalidAdUrl;
+ const uri = std.Uri.parse(address) catch return error.InvalidAdUrl;
+ if (!std.ascii.eqlIgnoreCase(uri.scheme, "https") or uri.host == null or
+ uri.user != null or uri.password != null or uri.fragment != null or
+ (uri.port != null and uri.port.? != 443)) return error.InvalidAdUrl;
+}
+
+fn url(node: *const xml.Node) ![]const u8 {
+ const result = node.content();
+ try validateUrl(result);
+ return result;
+}
+
+fn urls(allocator: std.mem.Allocator, node: *const xml.Node, name: []const u8) ![]const []const u8 {
+ var result = std.ArrayList([]const u8).init(allocator);
+ for (node.children.items) |child| if (std.mem.eql(u8, child.name, name)) {
+ if (result.items.len == 64) return error.VastLimitExceeded;
+ try result.append(try url(child));
+ };
+ return result.toOwnedSlice();
+}
+
+fn unsignedAttribute(comptime T: type, node: *const xml.Node, name: []const u8) !T {
+ return std.fmt.parseUnsigned(T, node.attributes.get(name) orelse return error.MissingAttribute, 10);
+}
+
+fn parseIcon(allocator: std.mem.Allocator, node: *const xml.Node) !Icon {
+ const image = node.child("StaticResource") orelse return error.UnsupportedIcon;
+ const kind = image.attributes.get("creativeType") orelse return error.UnsupportedIcon;
+ if (!std.mem.eql(u8, kind, "image/png") and !std.mem.eql(u8, kind, "image/jpeg")) return error.UnsupportedIcon;
+ const clicks = node.child("IconClicks");
+ var fallback: ?[]const u8 = null;
+ var fallback_width: u16 = 0;
+ var fallback_height: u16 = 0;
+ const program = node.attributes.get("program") orelse "";
+ var fallback_images = if (clicks) |click| click.child("IconClickFallbackImages") else null;
+ if (fallback_images == null) {
+ var ancestor: ?*const xml.Node = node.parent;
+ while (ancestor) |content| : (ancestor = content.parent) {
+ if (!std.mem.eql(u8, content.name, "InLine") and !std.mem.eql(u8, content.name, "Wrapper")) continue;
+ if (content.child("Extensions")) |extensions| for (extensions.children.items) |extension| {
+ if (!std.mem.eql(u8, extension.attributes.get("type") orelse "", "IconClickFallbackImages")) continue;
+ for (extension.children.items) |images| {
+ if (std.mem.eql(u8, images.name, "IconClickFallbackImages") and
+ std.mem.eql(u8, images.attributes.get("program") orelse "", program)) fallback_images = images;
+ }
+ };
+ break;
+ }
+ }
+ if (fallback_images) |images| for (images.children.items) |candidate| {
+ if (!std.mem.eql(u8, candidate.name, "IconClickFallbackImage")) continue;
+ const resource = candidate.child("StaticResource") orelse continue;
+ const resource_type = resource.attributes.get("creativeType") orelse continue;
+ if (!std.mem.eql(u8, resource_type, "image/png") and !std.mem.eql(u8, resource_type, "image/jpeg")) continue;
+ const width = try unsignedAttribute(u16, candidate, "width");
+ const height = try unsignedAttribute(u16, candidate, "height");
+ if (width == 0 or height == 0 or width > max_fallback_width or height > max_fallback_height) continue;
+ if (fallback != null and @as(u32, width) * height >= @as(u32, fallback_width) * fallback_height) continue;
+ fallback = try url(resource);
+ fallback_width = width;
+ fallback_height = height;
+ };
+ return .{
+ .program = program,
+ .image = try url(image),
+ .fallback = fallback,
+ .click_through = if (clicks) |click| if (click.child("IconClickThrough")) |target| try url(target) else null else null,
+ .views = try urls(allocator, node, "IconViewTracking"),
+ .clicks = if (clicks) |click| try urls(allocator, click, "IconClickTracking") else &.{},
+ .offset_ms = if (node.attributes.get("offset")) |offset| try duration(offset) else 0,
+ .duration_ms = if (node.attributes.get("duration")) |length| try duration(length) else null,
+ .width = try unsignedAttribute(u16, node, "width"),
+ .height = try unsignedAttribute(u16, node, "height"),
+ .x = try position(node.attributes.get("xPosition") orelse "right"),
+ .y = try position(node.attributes.get("yPosition") orelse "top"),
+ .fallback_width = fallback_width,
+ .fallback_height = fallback_height,
+ };
+}
+
+fn position(value: []const u8) !Position {
+ if (std.mem.eql(u8, value, "left") or std.mem.eql(u8, value, "top")) return .start;
+ if (std.mem.eql(u8, value, "right") or std.mem.eql(u8, value, "bottom")) return .end;
+ const pixel = try std.fmt.parseUnsigned(u16, value, 10);
+ if (pixel > 4096) return error.InvalidIconPosition;
+ return .{ .pixel = pixel };
+}
+
+fn parseTracks(allocator: std.mem.Allocator, node: *const xml.Node, duration_ms: ?u64) ![]const Track {
+ var tracks = std.ArrayList(Track).init(allocator);
+ if (node.child("TrackingEvents")) |events| for (events.children.items) |event| {
+ if (!std.mem.eql(u8, event.name, "Tracking")) continue;
+ if (tracks.items.len == 128) return error.VastLimitExceeded;
+ const name = event.attributes.get("event") orelse return error.MissingEvent;
+ var track = Track{ .event = name, .url = try url(event) };
+ if (std.mem.eql(u8, name, "progress")) {
+ const offset = event.attributes.get("offset") orelse return error.MissingOffset;
+ if (std.mem.endsWith(u8, offset, "%")) {
+ const percent = try std.fmt.parseUnsigned(u8, offset[0 .. offset.len - 1], 10);
+ if (percent > 100) return error.InvalidOffset;
+ if (duration_ms) |length| track.offset_ms = length * percent / 100 else track.offset_percent = percent;
+ } else track.offset_ms = try duration(offset);
+ }
+ try tracks.append(track);
+ };
+ return tracks.toOwnedSlice();
+}
+
+fn parseIcons(allocator: std.mem.Allocator, node: *const xml.Node) ![]Icon {
+ var icons = std.ArrayList(Icon).init(allocator);
+ if (node.child("Icons")) |group| for (group.children.items) |icon| {
+ if (!std.mem.eql(u8, icon.name, "Icon")) continue;
+ if (icons.items.len == 8) return error.VastLimitExceeded;
+ const parsed = try parseIcon(allocator, icon);
+ if (parsed.width == 0 or parsed.height == 0 or parsed.width > 640 or parsed.height > 480) return error.UnsupportedIcon;
+ try icons.append(parsed);
+ };
+ return icons.toOwnedSlice();
+}
+
+fn parseCreative(allocator: std.mem.Allocator, node: *const xml.Node) !Creative {
+ const length = node.child("Duration") orelse return error.MissingDuration;
+ const duration_ms = try duration(length.content());
+ if (duration_ms == 0 or duration_ms > 120000) return error.InvalidDuration;
+ const media_files = node.child("MediaFiles") orelse return error.MissingMedia;
+ var media = std.ArrayList(Media).init(allocator);
+ for (media_files.children.items) |file| {
+ if (!std.mem.eql(u8, file.name, "MediaFile")) continue;
+ const kind = file.attributes.get("type") orelse continue;
+ const delivery = file.attributes.get("delivery") orelse continue;
+ if (!std.mem.eql(u8, kind, "video/mp4") or !std.mem.eql(u8, delivery, "progressive")) continue;
+ const width = try unsignedAttribute(u16, file, "width");
+ const height = try unsignedAttribute(u16, file, "height");
+ if (width == 0 or height == 0 or width > 1280 or height > 768) continue;
+ if (media.items.len == 32) return error.VastLimitExceeded;
+ try media.append(.{ .url = try url(file), .width = width, .height = height, .bitrate = unsignedAttribute(u32, file, "bitrate") catch 0 });
+ }
+ if (media.items.len == 0) return error.UnsupportedMedia;
+ if (node.child("InteractiveCreativeFile") != null) return error.UnsupportedInteractiveAd;
+ return .{ .duration_ms = duration_ms, .media = try media.toOwnedSlice(), .tracks = try parseTracks(allocator, node, duration_ms), .icons = try parseIcons(allocator, node) };
+}
+
+fn booleanAttribute(node: *const xml.Node, name: []const u8, default: bool) !bool {
+ const value = node.attributes.get(name) orelse return default;
+ if (std.mem.eql(u8, value, "true") or std.mem.eql(u8, value, "1")) return true;
+ if (std.mem.eql(u8, value, "false") or std.mem.eql(u8, value, "0")) return false;
+ return error.InvalidWrapperAttribute;
+}
+
+pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) !Manifest {
+ var document = try xml.parse(allocator, bytes);
+ errdefer document.deinit();
+ if (!std.mem.eql(u8, document.root.name, "VAST")) return error.InvalidVastRoot;
+ const storage = document.arena.allocator();
+ var ads = std.ArrayList(Ad).init(storage);
+ for (document.root.children.items) |node| {
+ if (!std.mem.eql(u8, node.name, "Ad")) continue;
+ if (ads.items.len == 16) return error.VastLimitExceeded;
+ const content = node.child("InLine") orelse node.child("Wrapper") orelse return error.InvalidAd;
+ if (node.child("InLine") != null and node.child("Wrapper") != null) return error.InvalidAd;
+ const impressions = try urls(storage, content, "Impression");
+ const errors = try urls(storage, content, "Error");
+ var verification = std.ArrayList([]const u8).init(storage);
+ if (content.child("AdVerifications")) |group| for (group.children.items) |entry| {
+ if (entry.child("TrackingEvents")) |events| for (events.children.items) |event| {
+ if (std.mem.eql(u8, event.attributes.get("event") orelse "", "verificationNotExecuted")) {
+ if (verification.items.len == 64) return error.VastLimitExceeded;
+ try verification.append(try url(event));
+ }
+ };
+ };
+ const verification_errors = try verification.toOwnedSlice();
+ const sequence: ?u32 = if (node.attributes.get("sequence")) |value| try std.fmt.parseUnsigned(u32, value, 10) else null;
+ if (sequence == 0) return error.InvalidAdSequence;
+ if (sequence) |number| for (ads.items) |prior| {
+ if (prior.sequence == number) return error.DuplicateAdSequence;
+ };
+ if (std.mem.eql(u8, content.name, "Wrapper")) {
+ const destination = content.child("VASTAdTagURI") orelse return error.MissingWrapperUrl;
+ var tracks: []const Track = &.{};
+ var icons: []const Icon = &.{};
+ if (content.child("Creatives")) |creatives| for (creatives.children.items) |creative| {
+ if (creative.child("Linear")) |linear| {
+ tracks = try parseTracks(storage, linear, null);
+ icons = try parseIcons(storage, linear);
+ break;
+ }
+ };
+ try ads.append(.{ .sequence = sequence, .impressions = impressions, .errors = errors, .tracks = tracks, .icons = icons, .verification_errors = verification_errors, .body = .{ .wrapper = .{ .url = try url(destination), .follow_wrappers = try booleanAttribute(content, "followAdditionalWrappers", true), .allow_multiple = try booleanAttribute(content, "allowMultipleAds", false), .fallback_on_no_ad = try booleanAttribute(content, "fallbackOnNoAd", false) } } });
+ continue;
+ }
+ const creatives = content.child("Creatives") orelse return error.MissingCreative;
+ var selected: ?Creative = null;
+ for (creatives.children.items) |creative| {
+ if (!std.mem.eql(u8, creative.name, "Creative")) continue;
+ const linear = creative.child("Linear") orelse continue;
+ if (selected != null) return error.MultipleLinearCreatives;
+ selected = try parseCreative(storage, linear);
+ }
+ try ads.append(.{ .sequence = sequence, .impressions = impressions, .errors = errors, .verification_errors = verification_errors, .body = .{ .inline_ad = selected orelse return error.MissingLinearCreative } });
+ }
+ const errors = try urls(storage, document.root, "Error");
+ std.mem.sort(Ad, ads.items, {}, struct {
+ fn less(_: void, lhs: Ad, rhs: Ad) bool {
+ return (lhs.sequence orelse std.math.maxInt(u32)) < (rhs.sequence orelse std.math.maxInt(u32));
+ }
+ }.less);
+ return .{ .document = document, .ads = try ads.toOwnedSlice(), .errors = errors };
+}
+
+test "empty inventory, wrappers and inline MP4 metadata are distinct" {
+ var empty = try parse(std.testing.allocator, "");
+ defer empty.deinit();
+ try std.testing.expectEqual(@as(usize, 0), empty.ads.len);
+ var wrapper = try parse(std.testing.allocator, "https://example.invalid/viewhttps://example.invalid/tag");
+ defer wrapper.deinit();
+ try std.testing.expectEqualStrings("https://example.invalid/tag", wrapper.ads[0].body.wrapper.url);
+ var inline_ad = try parse(std.testing.allocator, "00:00:05.250" ++
+ "https://example.invalid/ad.mp4" ++
+ "https://example.invalid/progress" ++
+ "");
+ defer inline_ad.deinit();
+ try std.testing.expectEqual(@as(u64, 5250), inline_ad.ads[0].body.inline_ad.duration_ms);
+ try std.testing.expectEqual(@as(u64, 1312), inline_ad.ads[0].body.inline_ad.tracks[0].offset_ms.?);
+}
+
+test "malformed ads and dangerous URLs never become no-fill" {
+ try std.testing.expectError(error.InvalidAd, parse(std.testing.allocator, ""));
+ try std.testing.expectError(error.InvalidVastRoot, parse(std.testing.allocator, ""));
+ for ([_][]const u8{ "file:///tmp/video", "http://example.invalid/ad", "https://user:pass@example.invalid/ad", "https://example.invalid:444/ad" }) |value|
+ try std.testing.expectError(error.InvalidAdUrl, validateUrl(value));
+ try std.testing.expectEqual(@as(u64, 1250), try duration("00:00:01.25"));
+ for ([_][]const u8{ "00:61:00", "00:00:61", "00:00:01.", "00:00:01.1234", "-1:00:00", "00:00:01:02" }) |value|
+ try std.testing.expectError(error.InvalidDuration, duration(value));
+}
+
+test "VAST 3 AdChoices selects the smallest supported fallback for its program" {
+ var manifest = try parse(std.testing.allocator, "00:00:05" ++
+ "https://example.invalid/video" ++
+ "" ++
+ "https://example.invalid/icon" ++
+ "" ++
+ "https://example.invalid/wrong" ++
+ "" ++
+ "https://example.invalid/large" ++
+ "https://example.invalid/fallback" ++
+ "");
+ defer manifest.deinit();
+ const icon = manifest.ads[0].body.inline_ad.icons[0];
+ try std.testing.expectEqualStrings("https://example.invalid/fallback", icon.fallback.?);
+ try std.testing.expectEqual(@as(u16, 400), icon.fallback_width);
+}
+
+test "full HD AdChoices artwork is accepted within explicit decode bounds" {
+ for ([_][2]u16{ .{ 1920, 1080 }, .{ 1921, 1080 }, .{ 1920, 1081 }, .{ 0, 150 } }, 0..) |size, index| {
+ const bytes = try std.fmt.allocPrint(std.testing.allocator, "00:00:05" ++
+ "https://example.invalid/video" ++
+ "https://example.invalid/icon" ++
+ "" ++
+ "https://example.invalid/panel" ++
+ "", .{ size[0], size[1] });
+ defer std.testing.allocator.free(bytes);
+ var manifest = try parse(std.testing.allocator, bytes);
+ defer manifest.deinit();
+ try std.testing.expectEqual(index == 0, manifest.ads[0].body.inline_ad.icons[0].fallback != null);
+ }
+}
+
+test "wrapper flags and invalid sequence values are checked" {
+ var manifest = try parse(std.testing.allocator, "" ++
+ "https://example.invalid/tag");
+ defer manifest.deinit();
+ const wrapper = manifest.ads[0].body.wrapper;
+ try std.testing.expect(!wrapper.follow_wrappers and wrapper.allow_multiple and wrapper.fallback_on_no_ad);
+ try std.testing.expectError(error.InvalidAdSequence, parse(std.testing.allocator, "https://example.invalid/tag"));
+}
diff --git a/src/provider/geforce_now/webrtc_session.zig b/src/provider/geforce_now/webrtc_session.zig
new file mode 100644
index 0000000..788b6c5
--- /dev/null
+++ b/src/provider/geforce_now/webrtc_session.zig
@@ -0,0 +1,896 @@
+const std = @import("std");
+const cloudmatch = @import("cloudmatch_protocol.zig");
+const input_protocol = @import("input_protocol.zig");
+const pointer_input = @import("pointer_input.zig");
+const sdp_protocol = @import("sdp_protocol.zig");
+const signaling_client = @import("signaling_client.zig");
+const signaling_protocol = @import("signaling_protocol.zig");
+
+const c = @cImport({
+ @cInclude("SDL2/SDL.h");
+ @cInclude("rtc/rtc.h");
+ @cInclude("audio_pipeline.h");
+ @cInclude("controller.h");
+ @cInclude("handheld_ui.h");
+ @cInclude("video_pipeline.h");
+});
+
+const event_capacity = 64;
+const maximum_tracks = 8;
+const maximum_channels = 8;
+const maximum_bitrate_kbps = 6000;
+
+const EarlyIceCandidates = struct {
+ messages: std.BoundedArray(signaling_protocol.DecodedMessage, 32) = .{},
+
+ fn append(self: *EarlyIceCandidates, message: *signaling_protocol.DecodedMessage) !void {
+ const candidate = message.payload.ice;
+ if (signaling_protocol.isTcpIceCandidate(candidate.candidate)) return;
+ self.messages.append(message.*) catch return error.TooManyEarlyIceCandidates;
+ message.* = .{ .allocator = message.allocator };
+ }
+
+ fn deinit(self: *EarlyIceCandidates) void {
+ for (self.messages.slice()) |*message| message.deinit();
+ self.messages.len = 0;
+ }
+};
+
+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);
+}
+
+const EventKind = enum {
+ description,
+ candidate,
+ state,
+ track,
+ data_channel,
+ channel_open,
+ channel_message,
+};
+
+const Event = struct {
+ kind: EventKind,
+ id: c_int = -1,
+ state: c.rtcState = 0,
+ first: ?[:0]u8 = null,
+ second: ?[:0]u8 = null,
+ data: ?[]u8 = null,
+
+ fn deinit(self: *Event) void {
+ if (self.first) |value| std.heap.c_allocator.free(value);
+ if (self.second) |value| std.heap.c_allocator.free(value);
+ if (self.data) |value| std.heap.c_allocator.free(value);
+ self.* = undefined;
+ }
+};
+
+const EventQueue = struct {
+ events: [event_capacity]Event = undefined,
+ head: usize = 0,
+ tail: usize = 0,
+ count: usize = 0,
+ mutex: std.Thread.Mutex = .{},
+ overflowed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+
+ fn push(self: *EventQueue, event: Event) void {
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ if (self.count == self.events.len) {
+ var dropped = event;
+ dropped.deinit();
+ self.overflowed.store(true, .release);
+ return;
+ }
+ self.events[self.tail] = event;
+ self.tail = (self.tail + 1) % self.events.len;
+ self.count += 1;
+ }
+
+ fn pop(self: *EventQueue) ?Event {
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ if (self.count == 0) return null;
+ const event = self.events[self.head];
+ self.head = (self.head + 1) % self.events.len;
+ self.count -= 1;
+ return event;
+ }
+
+ fn deinit(self: *EventQueue) void {
+ while (self.pop()) |event_value| {
+ var event = event_value;
+ event.deinit();
+ }
+ }
+};
+
+pub const Session = struct {
+ allocator: std.mem.Allocator,
+ video: *c.GoVideoPipeline,
+ audio: *c.GoAudioPipeline,
+ controller: *c.GoControllerInput,
+ ui: *c.GoHandheldUi,
+ cloud: *const cloudmatch.Session,
+ width: u16,
+ height: u16,
+ frames_per_second: u16,
+ peer: c_int = -1,
+ input_channel: c_int = -1,
+ video_track: c_int = -1,
+ audio_track: c_int = -1,
+ tracks: [maximum_tracks]c_int = [_]c_int{-1} ** maximum_tracks,
+ track_count: usize = 0,
+ channels: [maximum_channels]c_int = [_]c_int{-1} ** maximum_channels,
+ channel_count: usize = 0,
+ signaling: ?*signaling_client.Client = null,
+ offer: ?[]u8 = null,
+ events: EventQueue = .{},
+ encoder: input_protocol.Encoder = .{},
+ pointer: pointer_input.State = .{},
+ answer_sent: bool = false,
+ input_ready: bool = false,
+ connected: bool = false,
+ closed: bool = false,
+ failed: bool = false,
+ shutting_down: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ started_at: u32 = 0,
+ last_input_heartbeat: u32 = 0,
+ bitrate_requested: bool = false,
+ video_packets: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
+ audio_packets: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
+ gamepad_logged: bool = false,
+
+ pub fn create(
+ allocator: std.mem.Allocator,
+ video_pointer: *anyopaque,
+ audio_pointer: *anyopaque,
+ controller_pointer: *anyopaque,
+ ui_pointer: *anyopaque,
+ cloud: *const cloudmatch.Session,
+ width: u16,
+ height: u16,
+ frames_per_second: u16,
+ ) !*Session {
+ if (cloud.signaling_url == null or cloud.id.len == 0) return error.MissingSignalingData;
+ const session = try allocator.create(Session);
+ session.* = .{
+ .allocator = allocator,
+ .video = @ptrCast(video_pointer),
+ .audio = @ptrCast(audio_pointer),
+ .controller = @ptrCast(controller_pointer),
+ .ui = @ptrCast(ui_pointer),
+ .cloud = cloud,
+ .width = width,
+ .height = height,
+ .frames_per_second = frames_per_second,
+ };
+ return session;
+ }
+
+ pub fn setup(self: *Session) !void {
+ var peer_name_buffer: [48]u8 = undefined;
+ var random: [8]u8 = undefined;
+ std.crypto.random.bytes(&random);
+ const peer_name = try std.fmt.bufPrint(
+ &peer_name_buffer,
+ "peer-{x:0>16}",
+ .{std.mem.readInt(u64, &random, .little)},
+ );
+ self.signaling = try signaling_client.Client.connect(
+ self.allocator,
+ self.cloud.signaling_url.?,
+ self.cloud.id,
+ peer_name,
+ self.width,
+ self.height,
+ );
+
+ var early_candidates: EarlyIceCandidates = .{};
+ defer early_candidates.deinit();
+ const offer = try self.waitForOffer(&early_candidates);
+ defer self.allocator.free(offer);
+ const sanitized = try sdp_protocol.sanitizeOffer(
+ self.allocator,
+ offer,
+ self.cloud.media_ip,
+ );
+ self.offer = sanitized;
+
+ const video_payload = sdp_protocol.codecPayloadType(sanitized, "H264/") orelse
+ return error.H264NotOffered;
+ const audio_payload = sdp_protocol.codecPayloadType(sanitized, "OPUS/") orelse
+ return error.OpusNotOffered;
+ debug("GeForce NOW offer: {d} bytes, H.264 PT {d}, Opus PT {d}\n", .{
+ sanitized.len,
+ video_payload,
+ audio_payload,
+ });
+ if (c.go_video_pipeline_set_payload_type(self.video, video_payload) != 0 or
+ c.go_audio_pipeline_set_payload_type(self.audio, audio_payload) != 0)
+ return error.MediaPipelineAlreadyActive;
+
+ try self.createPeer();
+ const offer_z = try self.allocator.dupeZ(u8, sanitized);
+ defer self.allocator.free(offer_z);
+ if (c.rtcSetRemoteDescription(self.peer, offer_z.ptr, "offer") < 0)
+ return error.RemoteDescriptionFailed;
+ for (early_candidates.messages.constSlice()) |*message| try self.handleSignaling(message);
+ early_candidates.deinit();
+ if (self.cloud.media_ip != null and self.cloud.media_port != null) {
+ if (try sdp_protocol.mediaHostCandidate(
+ self.allocator,
+ self.cloud.media_ip.?,
+ self.cloud.media_port.?,
+ )) |candidate| {
+ defer self.allocator.free(candidate);
+ if (c.rtcAddRemoteCandidate(self.peer, candidate.ptr, "0") < 0)
+ std.debug.print("GeForce NOW media ICE candidate was rejected\n", .{});
+ }
+ }
+ self.input_channel = c.rtcCreateDataChannel(self.peer, "input_channel_v1");
+ if (self.input_channel < 0) return error.InputChannelFailed;
+ try self.rememberChannel(self.input_channel);
+ c.rtcSetUserPointer(self.input_channel, self);
+ _ = c.rtcSetOpenCallback(self.input_channel, onChannelOpen);
+ _ = c.rtcSetMessageCallback(self.input_channel, onChannelMessage);
+ if (c.rtcSetLocalDescription(self.peer, "answer") < 0)
+ return error.LocalDescriptionFailed;
+
+ const deadline = c.SDL_GetTicks() +% 15_000;
+ while (!self.answer_sent and !self.failed and !self.closed) {
+ try self.pump();
+ if (deadlineReached(c.SDL_GetTicks(), deadline)) return error.AnswerTimeout;
+ if (c.go_handheld_ui_wait(self.ui, 16) != 0) return error.Cancelled;
+ }
+ if (!self.answer_sent) return error.AnswerFailed;
+ self.started_at = c.SDL_GetTicks();
+ self.last_input_heartbeat = self.started_at;
+ }
+
+ pub fn pump(self: *Session) !void {
+ if (self.events.overflowed.swap(false, .acq_rel)) return error.WebrtcEventOverflow;
+ var processed: usize = 0;
+ while (processed < event_capacity) : (processed += 1) {
+ const value = self.events.pop() orelse break;
+ var event = value;
+ defer event.deinit();
+ try self.handleEvent(&event);
+ }
+ var signaling_messages: usize = 0;
+ while (signaling_messages < 32) : (signaling_messages += 1) {
+ var message = (try self.signaling.?.poll()) orelse break;
+ defer message.deinit();
+ try self.handleSignaling(&message);
+ }
+
+ const now = c.SDL_GetTicks();
+ if (self.input_ready and now -% self.last_input_heartbeat >= 2000) {
+ var packet: [input_protocol.maximum_packet_size]u8 = undefined;
+ const heartbeat = try self.encoder.encodeHeartbeat(&packet);
+ if (c.rtcSendMessage(self.input_channel, @ptrCast(heartbeat.ptr), @intCast(heartbeat.len)) < 0)
+ return error.InputHeartbeatFailed;
+ self.last_input_heartbeat = now;
+ }
+ }
+
+ pub fn isConnected(self: *const Session) bool {
+ return self.connected;
+ }
+
+ pub fn isClosed(self: *const Session) bool {
+ return self.closed;
+ }
+
+ pub fn hasFailed(self: *const Session) bool {
+ return self.failed;
+ }
+
+ pub fn sendInput(self: *Session) !void {
+ if (!self.input_ready or self.input_channel < 0) return;
+ var controller_state = std.mem.zeroes(c.GoControllerState);
+ _ = c.go_controller_input_sample(self.controller, &controller_state);
+ const timestamp_us: u64 = @as(u64, c.SDL_GetTicks() -% self.started_at) * 1000;
+ var packet_buffer: [input_protocol.maximum_packet_size]u8 = undefined;
+ const update = self.pointer.update(.{
+ .buttons = geforceButtons(controller_state.buttons),
+ .left_trigger = scaleTrigger(controller_state.left_trigger),
+ .right_trigger = scaleTrigger(controller_state.right_trigger),
+ .left_x = controller_state.left_x,
+ .left_y = controller_state.left_y,
+ .right_x = controller_state.right_x,
+ .right_y = controller_state.right_y,
+ .timestamp_us = timestamp_us,
+ });
+ try self.sendInputPacket(try self.encoder.encodeReliableGamepad(&packet_buffer, update.gamepad));
+ if (update.moved) try self.sendInputPacket(try self.encoder.encodeMousePosition(
+ &packet_buffer,
+ @intFromFloat(self.pointer.x * @as(f32, @floatFromInt(self.width - 1))),
+ @intFromFloat(self.pointer.y * @as(f32, @floatFromInt(self.height - 1))),
+ self.width,
+ self.height,
+ timestamp_us,
+ ));
+ for ([_]input_protocol.MouseButton{ .left, .right }, 0..) |button, index| {
+ const mask = @as(u2, 1) << @as(u1, @intCast(index));
+ if (update.changed_buttons & mask != 0)
+ try self.sendInputPacket(try self.encoder.encodeMouseButton(
+ &packet_buffer,
+ button,
+ self.pointer.buttons & mask != 0,
+ timestamp_us,
+ ));
+ }
+ if (update.wheel != 0) try self.sendInputPacket(try self.encoder.encodeMouseWheel(&packet_buffer, update.wheel, timestamp_us));
+ if (update.toggled) debug("GeForce NOW input mode: {s}\n", .{if (self.pointer.enabled) "mouse" else "gamepad"});
+ if (!self.gamepad_logged and controllerStateIsActive(controller_state)) {
+ debug("GeForce NOW gamepad input active: protocol {d}, buttons 0x{x}\n", .{
+ self.encoder.protocol_version,
+ controller_state.buttons,
+ });
+ self.gamepad_logged = true;
+ }
+ }
+
+ fn sendInputPacket(self: *Session, packet: []const u8) !void {
+ if (c.rtcSendMessage(self.input_channel, @ptrCast(packet.ptr), @intCast(packet.len)) < 0)
+ return error.InputSendFailed;
+ }
+
+ pub fn requestKeyframe(self: *Session) void {
+ if (self.video_track >= 0) {
+ _ = c.rtcRequestKeyframe(self.video_track);
+ c.go_video_pipeline_note_keyframe_request(self.video);
+ }
+ }
+
+ pub fn requestBitrate(self: *Session) void {
+ if (self.bitrate_requested or self.video_track < 0 or
+ c.go_video_pipeline_has_media(self.video) == 0) return;
+ if (c.rtcRequestBitrate(self.video_track, maximum_bitrate_kbps * 1000) >= 0)
+ self.bitrate_requested = true;
+ }
+
+ pub fn destroy(self: *Session) void {
+ self.shutting_down.store(true, .release);
+ if (self.peer >= 0) {
+ _ = c.rtcClosePeerConnection(self.peer);
+ for (self.channels[0..self.channel_count]) |channel| _ = c.rtcDeleteDataChannel(channel);
+ for (self.tracks[0..self.track_count]) |track| _ = c.rtcDeleteTrack(track);
+ _ = c.rtcDeletePeerConnection(self.peer);
+ c.rtcCleanup();
+ }
+ if (self.signaling) |signaling| signaling.destroy();
+ if (self.offer) |offer| self.allocator.free(offer);
+ self.events.deinit();
+ const allocator = self.allocator;
+ allocator.destroy(self);
+ }
+
+ fn waitForOffer(self: *Session, early_candidates: *EarlyIceCandidates) ![]u8 {
+ const deadline = c.SDL_GetTicks() +% 30_000;
+ while (!deadlineReached(c.SDL_GetTicks(), deadline)) {
+ if (try self.signaling.?.poll()) |message_value| {
+ var message = message_value;
+ defer message.deinit();
+ if (message.peer_removed) return error.RemotePeerRemoved;
+ switch (message.payload) {
+ .offer => |offer| return self.allocator.dupe(u8, offer),
+ .ice => try early_candidates.append(&message),
+ .bye => return error.SignalingClosed,
+ else => {},
+ }
+ }
+ if (c.go_handheld_ui_wait(self.ui, 16) != 0) return error.Cancelled;
+ }
+ return error.OfferTimeout;
+ }
+
+ fn createPeer(self: *Session) !void {
+ const ice_urls = try buildIceUrls(self.allocator, self.cloud.ice_servers);
+ defer freeIceUrls(self.allocator, ice_urls);
+ var pointers = try self.allocator.alloc([*c]const u8, ice_urls.len);
+ defer self.allocator.free(pointers);
+ for (ice_urls, 0..) |url, index| pointers[index] = url.ptr;
+
+ var configuration = std.mem.zeroes(c.rtcConfiguration);
+ configuration.iceServers = if (pointers.len > 0) @ptrCast(pointers.ptr) else null;
+ configuration.iceServersCount = @intCast(pointers.len);
+ configuration.disableAutoNegotiation = true;
+ configuration.maxMessageSize = 262_144;
+ self.peer = c.rtcCreatePeerConnection(&configuration);
+ if (self.peer < 0) return error.PeerCreationFailed;
+ c.rtcSetUserPointer(self.peer, self);
+ _ = c.rtcSetLocalDescriptionCallback(self.peer, onDescription);
+ _ = c.rtcSetLocalCandidateCallback(self.peer, onCandidate);
+ _ = c.rtcSetStateChangeCallback(self.peer, onStateChange);
+ _ = c.rtcSetTrackCallback(self.peer, onTrack);
+ _ = c.rtcSetDataChannelCallback(self.peer, onDataChannel);
+ }
+
+ fn handleEvent(self: *Session, event: *Event) !void {
+ switch (event.kind) {
+ .description => {
+ const sdp = event.first orelse return;
+ const kind = event.second orelse return;
+ if (!std.mem.eql(u8, std.mem.sliceTo(kind, 0), "answer")) return;
+ const narrowed_sdp = try sdp_protocol.narrowAnswerToH264Opus(
+ self.allocator,
+ std.mem.sliceTo(sdp, 0),
+ self.offer.?,
+ );
+ defer self.allocator.free(narrowed_sdp);
+ const nvst = try sdp_protocol.buildNvstAnswer(
+ self.allocator,
+ narrowed_sdp,
+ self.offer.?,
+ self.width,
+ self.height,
+ self.frames_per_second,
+ maximum_bitrate_kbps,
+ );
+ defer self.allocator.free(nvst);
+ try self.signaling.?.sendAnswer(narrowed_sdp, nvst);
+ self.answer_sent = true;
+ },
+ .candidate => {
+ const candidate = event.first orelse return;
+ try self.signaling.?.sendIceCandidate(
+ std.mem.sliceTo(candidate, 0),
+ if (event.second) |mid| std.mem.sliceTo(mid, 0) else null,
+ );
+ },
+ .state => switch (event.state) {
+ c.RTC_CONNECTED => {
+ self.connected = true;
+ self.logTransport();
+ },
+ c.RTC_FAILED => {
+ self.connected = false;
+ self.failed = true;
+ },
+ c.RTC_CLOSED => {
+ self.connected = false;
+ self.closed = true;
+ },
+ else => {},
+ },
+ .track => try self.configureTrack(event.id),
+ .data_channel => try self.configureIncomingChannel(event.id),
+ .channel_open => {
+ if (event.id == self.input_channel) {
+ debug("GeForce NOW input channel open\n", .{});
+ }
+ },
+ .channel_message => if (event.id == self.input_channel and event.data != null) {
+ if (input_protocol.parseHandshakeVersion(event.data.?)) |version| {
+ self.encoder.setProtocolVersion(version);
+ self.input_ready = true;
+ debug("GeForce NOW input handshake: protocol {d}\n", .{version});
+ }
+ },
+ }
+ }
+
+ fn handleSignaling(self: *Session, message: *const signaling_protocol.DecodedMessage) !void {
+ if (message.peer_removed) {
+ self.closed = true;
+ return;
+ }
+ switch (message.payload) {
+ .ice => |candidate| {
+ if (signaling_protocol.isTcpIceCandidate(candidate.candidate)) return;
+ const value = try self.allocator.dupeZ(u8, candidate.candidate);
+ defer self.allocator.free(value);
+ const mid_value = try sdp_protocol.iceCandidateMid(
+ self.offer orelse return error.MissingOffer,
+ candidate.sdp_mid,
+ candidate.sdp_m_line_index,
+ );
+ const mid = try self.allocator.dupeZ(u8, mid_value);
+ defer self.allocator.free(mid);
+ if (c.rtcAddRemoteCandidate(self.peer, value.ptr, mid.ptr) < 0)
+ return error.RemoteCandidateRejected;
+ },
+ .bye => self.closed = true,
+ else => {},
+ }
+ }
+
+ fn configureTrack(self: *Session, track: c_int) !void {
+ if (track < 0) return;
+ self.rememberTrack(track) catch |err| {
+ _ = c.rtcDeleteTrack(track);
+ return err;
+ };
+ c.rtcSetUserPointer(track, self);
+ var description_buffer: [4096]u8 = undefined;
+ const length = c.rtcGetTrackDescription(track, &description_buffer, description_buffer.len);
+ if (length <= 0) return error.TrackDescriptionUnavailable;
+ const description = description_buffer[0..@intCast(length - 1)];
+ var mid_buffer: [64]u8 = undefined;
+ const mid_length = c.rtcGetTrackMid(track, &mid_buffer, mid_buffer.len);
+ const mid = if (mid_length > 0)
+ std.mem.sliceTo(mid_buffer[0..@intCast(mid_length)], 0)
+ else
+ "?";
+ if (std.ascii.indexOfIgnoreCase(description, "H264/") != null) {
+ if (self.video_track >= 0) return error.MultipleVideoTracks;
+ self.video_track = track;
+ if (c.rtcChainRtcpReceivingSession(track) < 0) return error.VideoTrackFailed;
+ _ = c.rtcSetMessageCallback(track, onVideoMessage);
+ debug("GeForce NOW video track: mid {s}\n", .{mid});
+ } else if (std.ascii.indexOfIgnoreCase(description, "opus/") != null) {
+ const role = mediaStreamTrackId(description) orelse {
+ debug("Ignoring GeForce NOW audio track without a role: mid {s}\n", .{mid});
+ return;
+ };
+ if (!std.ascii.eqlIgnoreCase(role, "audio")) {
+ debug("Ignoring GeForce NOW audio track {s}: mid {s}\n", .{ role, mid });
+ return;
+ }
+ if (self.audio_track >= 0) return error.MultipleGameAudioTracks;
+ self.audio_track = track;
+ if (c.rtcChainRtcpReceivingSession(track) < 0) return error.AudioTrackFailed;
+ _ = c.rtcSetMessageCallback(track, onAudioMessage);
+ debug("GeForce NOW audio track: mid {s}\n", .{mid});
+ } else {
+ debug("GeForce NOW unhandled track: mid {s}\n", .{mid});
+ }
+ }
+
+ fn configureIncomingChannel(self: *Session, channel: c_int) !void {
+ if (channel < 0) return;
+ self.rememberChannel(channel) catch |err| {
+ _ = c.rtcDeleteDataChannel(channel);
+ return err;
+ };
+ c.rtcSetUserPointer(channel, self);
+ _ = c.rtcSetOpenCallback(channel, onChannelOpen);
+ _ = c.rtcSetMessageCallback(channel, onChannelMessage);
+ }
+
+ fn rememberTrack(self: *Session, track: c_int) !void {
+ for (self.tracks[0..self.track_count]) |existing| if (existing == track) return;
+ if (self.track_count == self.tracks.len) return error.TooManyTracks;
+ self.tracks[self.track_count] = track;
+ self.track_count += 1;
+ }
+
+ fn rememberChannel(self: *Session, channel: c_int) !void {
+ for (self.channels[0..self.channel_count]) |existing| if (existing == channel) return;
+ if (self.channel_count == self.channels.len) return error.TooManyChannels;
+ self.channels[self.channel_count] = channel;
+ self.channel_count += 1;
+ }
+
+ fn logTransport(self: *Session) void {
+ if (!debugEnabled() or self.peer < 0) return;
+ var local = [_]u8{0} ** 128;
+ var remote = [_]u8{0} ** 128;
+ const result = c.rtcGetSelectedCandidatePair(
+ self.peer,
+ &local,
+ local.len,
+ &remote,
+ remote.len,
+ );
+ if (result >= 0) {
+ debug("GeForce NOW transport connected: {s} -> {s}\n", .{
+ std.mem.sliceTo(&local, 0),
+ std.mem.sliceTo(&remote, 0),
+ });
+ } else {
+ debug("GeForce NOW transport connected; selected ICE pair unavailable\n", .{});
+ }
+ }
+};
+
+fn sessionFromContext(context: ?*anyopaque) ?*Session {
+ return @ptrCast(@alignCast(context orelse return null));
+}
+
+fn enqueueStrings(session: *Session, kind: EventKind, first: [*c]const u8, second: [*c]const u8) void {
+ if (session.shutting_down.load(.acquire) or first == null) return;
+ const first_copy = std.heap.c_allocator.dupeZ(u8, std.mem.span(first)) catch return;
+ const second_copy = if (second != null)
+ std.heap.c_allocator.dupeZ(u8, std.mem.span(second)) catch {
+ std.heap.c_allocator.free(first_copy);
+ return;
+ }
+ else
+ null;
+ session.events.push(.{ .kind = kind, .first = first_copy, .second = second_copy });
+}
+
+fn onDescription(_: c_int, sdp: [*c]const u8, kind: [*c]const u8, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ enqueueStrings(session, .description, sdp, kind);
+}
+
+fn onCandidate(_: c_int, candidate: [*c]const u8, mid: [*c]const u8, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ enqueueStrings(session, .candidate, candidate, mid);
+}
+
+fn onStateChange(_: c_int, state: c.rtcState, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ if (!session.shutting_down.load(.acquire))
+ session.events.push(.{ .kind = .state, .state = state });
+}
+
+fn onTrack(_: c_int, track: c_int, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ if (!session.shutting_down.load(.acquire))
+ session.events.push(.{ .kind = .track, .id = track });
+}
+
+fn onDataChannel(_: c_int, channel: c_int, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ if (!session.shutting_down.load(.acquire))
+ session.events.push(.{ .kind = .data_channel, .id = channel });
+}
+
+fn onChannelOpen(channel: c_int, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ if (!session.shutting_down.load(.acquire))
+ session.events.push(.{ .kind = .channel_open, .id = channel });
+}
+
+fn onChannelMessage(channel: c_int, data: [*c]const u8, size: c_int, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ if (session.shutting_down.load(.acquire) or data == null or size == 0) return;
+ const signed_size: i64 = size;
+ const length: usize = @intCast(if (signed_size < 0) -signed_size else signed_size);
+ if (length > 4096) return;
+ const copy = std.heap.c_allocator.dupe(u8, data[0..length]) catch return;
+ session.events.push(.{ .kind = .channel_message, .id = channel, .data = copy });
+}
+
+fn onVideoMessage(track: c_int, data: [*c]const u8, size: c_int, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ if (session.shutting_down.load(.acquire) or track != session.video_track or data == null or size <= 0) return;
+ if (session.video_packets.fetchAdd(1, .monotonic) == 0)
+ debug("GeForce NOW first video RTP packet: {d} bytes\n", .{size});
+ c.go_video_pipeline_push_rtp(session.video, @ptrCast(data), @intCast(size));
+}
+
+fn onAudioMessage(track: c_int, data: [*c]const u8, size: c_int, context: ?*anyopaque) callconv(.c) void {
+ const session = sessionFromContext(context) orelse return;
+ if (session.shutting_down.load(.acquire) or track != session.audio_track or data == null or size <= 0) return;
+ if (session.audio_packets.fetchAdd(1, .monotonic) == 0)
+ debug("GeForce NOW first audio RTP packet: {d} bytes\n", .{size});
+ c.go_audio_pipeline_push_rtp(session.audio, @ptrCast(data), @intCast(size));
+}
+
+fn deadlineReached(now: u32, deadline: u32) bool {
+ return @as(i32, @bitCast(now -% deadline)) >= 0;
+}
+
+fn mediaStreamTrackId(description: []const u8) ?[]const u8 {
+ var lines = std.mem.splitScalar(u8, description, '\n');
+ while (lines.next()) |raw_line| {
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (!std.mem.startsWith(u8, line, "a=msid:")) continue;
+ var fields = std.mem.tokenizeAny(u8, line["a=msid:".len..], " \t");
+ const stream_id = fields.next() orelse continue;
+ return fields.next() orelse stream_id;
+ }
+ return null;
+}
+
+fn scaleTrigger(value: u16) u8 {
+ return @intCast((@as(u32, value) * 255 + 32767) / 65535);
+}
+
+fn geforceButtons(source: u32) u16 {
+ var result: u16 = 0;
+ const mappings = [_]struct { u32, u16 }{
+ .{ c.GO_CONTROLLER_BUTTON_DPAD_UP, input_protocol.Button.dpad_up },
+ .{ c.GO_CONTROLLER_BUTTON_DPAD_DOWN, input_protocol.Button.dpad_down },
+ .{ c.GO_CONTROLLER_BUTTON_DPAD_LEFT, input_protocol.Button.dpad_left },
+ .{ c.GO_CONTROLLER_BUTTON_DPAD_RIGHT, input_protocol.Button.dpad_right },
+ .{ c.GO_CONTROLLER_BUTTON_START, input_protocol.Button.start },
+ .{ c.GO_CONTROLLER_BUTTON_BACK, input_protocol.Button.back },
+ .{ c.GO_CONTROLLER_BUTTON_LEFT_STICK, input_protocol.Button.left_stick },
+ .{ c.GO_CONTROLLER_BUTTON_RIGHT_STICK, input_protocol.Button.right_stick },
+ .{ c.GO_CONTROLLER_BUTTON_LEFT_SHOULDER, input_protocol.Button.left_shoulder },
+ .{ c.GO_CONTROLLER_BUTTON_RIGHT_SHOULDER, input_protocol.Button.right_shoulder },
+ .{ c.GO_CONTROLLER_BUTTON_GUIDE, input_protocol.Button.guide },
+ .{ c.GO_CONTROLLER_BUTTON_A, input_protocol.Button.a },
+ .{ c.GO_CONTROLLER_BUTTON_B, input_protocol.Button.b },
+ .{ c.GO_CONTROLLER_BUTTON_X, input_protocol.Button.x },
+ .{ c.GO_CONTROLLER_BUTTON_Y, input_protocol.Button.y },
+ };
+ for (mappings) |mapping| {
+ if (source & mapping[0] != 0) result |= mapping[1];
+ }
+ return result;
+}
+
+fn controllerStateIsActive(state: c.GoControllerState) bool {
+ return state.buttons != 0 or state.left_trigger != 0 or state.right_trigger != 0 or
+ state.left_x != 0 or state.left_y != 0 or state.right_x != 0 or state.right_y != 0;
+}
+
+fn buildIceUrls(
+ allocator: std.mem.Allocator,
+ servers: []const cloudmatch.IceServer,
+) ![][:0]u8 {
+ var result = std.ArrayList([:0]u8).init(allocator);
+ errdefer {
+ for (result.items) |url| allocator.free(url);
+ result.deinit();
+ }
+ for (servers) |server| {
+ for (server.urls) |url| {
+ const configured = if (server.username != null and server.credential != null and
+ (std.ascii.startsWithIgnoreCase(url, "turn:") or
+ std.ascii.startsWithIgnoreCase(url, "turns:")))
+ try addIceCredentials(allocator, url, server.username.?, server.credential.?)
+ else
+ try allocator.dupeZ(u8, url);
+ errdefer allocator.free(configured);
+ try result.append(configured);
+ }
+ }
+ return result.toOwnedSlice();
+}
+
+fn freeIceUrls(allocator: std.mem.Allocator, urls: [][:0]u8) void {
+ for (urls) |url| allocator.free(url);
+ allocator.free(urls);
+}
+
+fn addIceCredentials(
+ allocator: std.mem.Allocator,
+ url: []const u8,
+ username: []const u8,
+ credential: []const u8,
+) ![:0]u8 {
+ const separator = std.mem.indexOfScalar(u8, url, ':') orelse return error.InvalidIceUrl;
+ var result = std.ArrayList(u8).init(allocator);
+ errdefer result.deinit();
+ try result.appendSlice(url[0 .. separator + 1]);
+ try writeUrlComponent(result.writer(), username);
+ try result.append(':');
+ try writeUrlComponent(result.writer(), credential);
+ try result.append('@');
+ try result.appendSlice(url[separator + 1 ..]);
+ return result.toOwnedSliceSentinel(0);
+}
+
+fn writeUrlComponent(writer: anytype, value: []const u8) !void {
+ const hex = "0123456789ABCDEF";
+ for (value) |byte| {
+ if (std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '_' or byte == '.' or byte == '~') {
+ try writer.writeByte(byte);
+ } else {
+ try writer.writeByte('%');
+ try writer.writeByte(hex[byte >> 4]);
+ try writer.writeByte(hex[byte & 0x0f]);
+ }
+ }
+}
+
+test "early ICE candidates retain ownership and arrival order until the offer" {
+ var pending: EarlyIceCandidates = .{};
+ defer pending.deinit();
+ const inputs = [_][]const u8{
+ \\{"peer_msg":{"from":7,"msg":"{\"candidate\":\"candidate:1 1 UDP 1 192.0.2.1 10000 typ host\",\"sdpMid\":\"video\"}"}}
+ ,
+ \\{"peer_msg":{"from":7,"msg":"{\"candidate\":\"candidate:2 1 UDP 1 192.0.2.2 10001 typ host\"}"}}
+ };
+ for (inputs) |input| {
+ var message = try signaling_protocol.decode(std.testing.allocator, input);
+ defer message.deinit();
+ try pending.append(&message);
+ try std.testing.expect(message.payload == .none);
+ }
+ try std.testing.expectEqual(@as(usize, 2), pending.messages.len);
+ const messages = pending.messages.constSlice();
+ try std.testing.expectEqualStrings("candidate:1 1 UDP 1 192.0.2.1 10000 typ host", messages[0].payload.ice.candidate);
+ try std.testing.expectEqualStrings("video", messages[0].payload.ice.sdp_mid.?);
+ try std.testing.expectEqualStrings("candidate:2 1 UDP 1 192.0.2.2 10001 typ host", messages[1].payload.ice.candidate);
+ try std.testing.expect(messages[1].payload.ice.sdp_mid == null);
+ pending.deinit();
+ try std.testing.expectEqual(@as(usize, 0), pending.messages.len);
+}
+
+test "early ICE ignores TCP and leaves ownership with the caller on overflow" {
+ var pending: EarlyIceCandidates = .{};
+ defer pending.deinit();
+ var tcp = try signaling_protocol.decode(std.testing.allocator,
+ \\{"peer_msg":{"msg":"{\"candidate\":\"candidate:1 1 TCP 1 192.0.2.1 10000 typ host tcptype passive\"}"}}
+ );
+ defer tcp.deinit();
+ try pending.append(&tcp);
+ try std.testing.expectEqual(@as(usize, 0), pending.messages.len);
+ try std.testing.expect(tcp.payload == .ice);
+
+ for (0..pending.messages.buffer.len + 1) |index| {
+ var message = try signaling_protocol.decode(std.testing.allocator,
+ \\{"peer_info":{"id":7,"name":"server"},"peer_msg":{"msg":"{\"candidate\":\"candidate:1 1 UDP 1 192.0.2.1 10000 typ host\",\"usernameFragment\":\"test\"}"}}
+ );
+ defer message.deinit();
+ if (index < pending.messages.buffer.len) {
+ try pending.append(&message);
+ try std.testing.expect(message.peer_info == null);
+ } else {
+ try std.testing.expectError(error.TooManyEarlyIceCandidates, pending.append(&message));
+ try std.testing.expect(message.payload == .ice);
+ }
+ }
+}
+
+test "controller state maps to XInput masks used by GeForce NOW" {
+ const source = c.GO_CONTROLLER_BUTTON_A | c.GO_CONTROLLER_BUTTON_DPAD_LEFT |
+ c.GO_CONTROLLER_BUTTON_GUIDE;
+ try std.testing.expectEqual(
+ input_protocol.Button.a | input_protocol.Button.dpad_left | input_protocol.Button.guide,
+ geforceButtons(source),
+ );
+ try std.testing.expectEqual(@as(u8, 255), scaleTrigger(65535));
+}
+
+test "controller activity ignores neutral samples" {
+ try std.testing.expect(!controllerStateIsActive(std.mem.zeroes(c.GoControllerState)));
+
+ var state = std.mem.zeroes(c.GoControllerState);
+ state.right_x = 1;
+ try std.testing.expect(controllerStateIsActive(state));
+}
+
+test "TURN credentials are percent encoded into the libdatachannel URL" {
+ const result = try addIceCredentials(
+ std.testing.allocator,
+ "turn:example.com:3478?transport=udp",
+ "user@example",
+ "p:a ss",
+ );
+ defer std.testing.allocator.free(result);
+ try std.testing.expectEqualStrings(
+ "turn:user%40example:p%3Aa%20ss@example.com:3478?transport=udp",
+ result,
+ );
+}
+
+test "GeForce NOW media track roles come from msid" {
+ try std.testing.expectEqualStrings(
+ "audio",
+ mediaStreamTrackId("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=msid:second_stream_id audio\r\n").?,
+ );
+ try std.testing.expectEqualStrings(
+ "mic",
+ mediaStreamTrackId("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=msid:third_stream_id mic\r\n").?,
+ );
+ try std.testing.expectEqualStrings(
+ "audio",
+ mediaStreamTrackId("m=audio 0 RTP/AVP\r\na=msid:audio\r\n").?,
+ );
+ try std.testing.expect(mediaStreamTrackId("m=audio 0 RTP/AVP\r\n") == null);
+}
+
+test "ICE URL construction releases partial allocations" {
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
+ fn run(allocator: std.mem.Allocator) !void {
+ var stun = "stun:example.invalid:3478".*;
+ var turn = "turn:example.invalid:3478".*;
+ var username = "user".*;
+ var credential = "test".*;
+ var urls = [_][]u8{ &stun, &turn };
+ const servers = [_]cloudmatch.IceServer{.{
+ .urls = &urls,
+ .username = &username,
+ .credential = &credential,
+ }};
+ const result = try buildIceUrls(allocator, &servers);
+ defer freeIceUrls(allocator, result);
+ }
+ }.run, .{});
+}
diff --git a/src/session/webrtc_session.zig b/src/session/webrtc_session.zig
index 08235c9..b9bbaf5 100644
--- a/src/session/webrtc_session.zig
+++ b/src/session/webrtc_session.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const json_reader = @import("json_reader");
const json_writer = @import("json_writer");
+const uuid = @import("uuid");
const message_protocol = @import("message_protocol.zig");
const c = @cImport({
@@ -31,7 +32,7 @@ const Session = struct {
audio_track: c_int = -1,
stream_width: c_uint,
stream_height: c_uint,
- install_id: [37]u8 = [_]u8{0} ** 37,
+ install_id: [uuid.string_length + 1]u8 = [_]u8{0} ** (uuid.string_length + 1),
connected: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
gathering_complete: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
handshake_complete: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
@@ -69,26 +70,6 @@ fn messageData(data: [*c]const u8, size: c_int) ?[]const u8 {
return data[0..length];
}
-fn generateInstallId(output: *[37]u8) void {
- var bytes: [16]u8 = undefined;
- std.crypto.random.bytes(&bytes);
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
- _ = std.fmt.bufPrintZ(
- output,
- "{x:0>2}{x:0>2}{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-" ++
- "{x:0>2}{x:0>2}-{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}",
- .{
- bytes[0], bytes[1], bytes[2], bytes[3],
- bytes[4], bytes[5], bytes[6], bytes[7],
- bytes[8], bytes[9], bytes[10], bytes[11],
- bytes[12], bytes[13], bytes[14], bytes[15],
- },
- ) catch {
- @memcpy(output, "00000000-0000-4000-8000-000000000000\x00");
- };
-}
-
fn onDescription(_: c_int, sdp: [*c]const u8, _: [*c]const u8, _: ?*anyopaque) callconv(.c) void {
if (cString(sdp)) |value| debug("Local SDP ready ({d} bytes)\n", .{value.len});
}
@@ -332,7 +313,7 @@ pub export fn go_webrtc_session_create(
.stream_width = stream_width,
.stream_height = stream_height,
};
- generateInstallId(&session.install_id);
+ uuid.generate(&session.install_id);
return session;
}
diff --git a/src/ui/artwork_decoder.c b/src/ui/artwork_decoder.c
index 2d40365..345044d 100644
--- a/src/ui/artwork_decoder.c
+++ b/src/ui/artwork_decoder.c
@@ -1,33 +1,33 @@
#include "artwork_decoder.h"
-#include
#include
#include
#include
#include
#include
+#include
#include
#include
static void normalize_jpeg_pixel_format(AVFrame* frame) {
switch ((enum AVPixelFormat)frame->format) {
- case AV_PIX_FMT_YUVJ420P:
- frame->format = AV_PIX_FMT_YUV420P;
- break;
- case AV_PIX_FMT_YUVJ422P:
- frame->format = AV_PIX_FMT_YUV422P;
- break;
- case AV_PIX_FMT_YUVJ444P:
- frame->format = AV_PIX_FMT_YUV444P;
- break;
- case AV_PIX_FMT_YUVJ440P:
- frame->format = AV_PIX_FMT_YUV440P;
- break;
- case AV_PIX_FMT_YUVJ411P:
- frame->format = AV_PIX_FMT_YUV411P;
- break;
- default:
- return;
+ case AV_PIX_FMT_YUVJ420P:
+ frame->format = AV_PIX_FMT_YUV420P;
+ break;
+ case AV_PIX_FMT_YUVJ422P:
+ frame->format = AV_PIX_FMT_YUV422P;
+ break;
+ case AV_PIX_FMT_YUVJ444P:
+ frame->format = AV_PIX_FMT_YUV444P;
+ break;
+ case AV_PIX_FMT_YUVJ440P:
+ frame->format = AV_PIX_FMT_YUV440P;
+ break;
+ case AV_PIX_FMT_YUVJ411P:
+ frame->format = AV_PIX_FMT_YUV411P;
+ break;
+ default:
+ return;
}
frame->color_range = AVCOL_RANGE_JPEG;
}
@@ -39,14 +39,14 @@ void go_artwork_image_destroy(GoArtworkImage* image) {
memset(image, 0, sizeof(*image));
}
-int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, int max_height,
- GoArtworkImage* output) {
- if (!data || length == 0 || length > INT_MAX || max_width <= 0 || max_height <= 0 ||
- !output)
+static int decode_image(const uint8_t* data, size_t length, int max_width, int max_height,
+ enum AVCodecID codec_id, enum AVPixelFormat pixel_format,
+ int bytes_per_pixel, GoArtworkImage* output) {
+ if (!data || length == 0 || length > INT_MAX || max_width <= 0 || max_height <= 0 || !output)
return -1;
memset(output, 0, sizeof(*output));
- const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG);
+ const AVCodec* codec = avcodec_find_decoder(codec_id);
if (!codec)
return -1;
AVCodecContext* context = codec ? avcodec_alloc_context3(codec) : NULL;
@@ -68,20 +68,20 @@ int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, in
goto cleanup;
normalize_jpeg_pixel_format(frame);
if (frame->width <= 0 || frame->height <= 0 || frame->width > max_width ||
- frame->height > max_height || frame->width > INT_MAX / 3)
+ frame->height > max_height || frame->width > INT_MAX / bytes_per_pixel)
goto cleanup;
- size_t stride = (size_t)frame->width * 3;
+ size_t stride = (size_t)frame->width * bytes_per_pixel;
if ((size_t)frame->height > SIZE_MAX / stride || stride > INT_MAX)
goto cleanup;
output->pixels = malloc(stride * (size_t)frame->height);
if (!output->pixels)
goto cleanup;
- rgb->format = AV_PIX_FMT_RGB24;
+ rgb->format = pixel_format;
rgb->width = frame->width;
rgb->height = frame->height;
rgb->color_range = AVCOL_RANGE_JPEG;
- if (av_image_fill_arrays(rgb->data, rgb->linesize, output->pixels, AV_PIX_FMT_RGB24,
- frame->width, frame->height, 1) < 0)
+ if (av_image_fill_arrays(rgb->data, rgb->linesize, output->pixels, pixel_format, frame->width,
+ frame->height, 1) < 0)
goto cleanup;
scaler = sws_alloc_context();
if (!scaler)
@@ -104,3 +104,20 @@ int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, in
avcodec_free_context(&context);
return result;
}
+
+int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, int max_height,
+ GoArtworkImage* output) {
+ return decode_image(data, length, max_width, max_height, AV_CODEC_ID_MJPEG, AV_PIX_FMT_RGB24, 3,
+ output);
+}
+
+int go_artwork_decode_rgba(const uint8_t* data, size_t length, int max_width, int max_height,
+ GoArtworkImage* output) {
+ static const uint8_t png_signature[] = {137, 80, 78, 71, 13, 10, 26, 10};
+ if (!data || length < 8)
+ return -1;
+ enum AVCodecID codec = memcmp(data, png_signature, sizeof(png_signature)) == 0
+ ? AV_CODEC_ID_PNG
+ : AV_CODEC_ID_MJPEG;
+ return decode_image(data, length, max_width, max_height, codec, AV_PIX_FMT_RGBA, 4, output);
+}
diff --git a/src/ui/artwork_decoder.h b/src/ui/artwork_decoder.h
index 04f395f..124ea87 100644
--- a/src/ui/artwork_decoder.h
+++ b/src/ui/artwork_decoder.h
@@ -13,6 +13,8 @@ typedef struct {
int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, int max_height,
GoArtworkImage* output);
+int go_artwork_decode_rgba(const uint8_t* data, size_t length, int max_width, int max_height,
+ GoArtworkImage* output);
void go_artwork_image_destroy(GoArtworkImage* image);
#endif
diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h
index 8f53382..160f9ab 100644
--- a/src/ui/handheld_ui.h
+++ b/src/ui/handheld_ui.h
@@ -16,8 +16,15 @@ typedef int (*GoUiStopRequested)(void* context);
enum {
GO_HANDHELD_UI_PICK_CANCELLED = -1,
GO_HANDHELD_UI_PICK_SIGN_OUT = -2,
+ GO_HANDHELD_UI_PICK_CHANGE_PROVIDER = -3,
};
+typedef enum {
+ GO_HANDHELD_UI_PROVIDER_CANCELLED = -1,
+ GO_HANDHELD_UI_PROVIDER_XBOX = 0,
+ GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW = 1,
+} GoHandheldUiProvider;
+
typedef enum {
GO_HANDHELD_UI_ACTION_NONE = 0,
GO_HANDHELD_UI_ACTION_BACK,
@@ -33,15 +40,27 @@ void go_handheld_ui_draw_loading(GoHandheldUi* ui, const char* heading, const ch
GoHandheldUiAction action);
void go_handheld_ui_draw_device_code(GoHandheldUi* ui, const char* user_code, const char* status,
unsigned int seconds_remaining);
+void go_handheld_ui_draw_device_code_for(GoHandheldUi* ui, const char* service, const char* address,
+ const char* user_code, const char* status,
+ unsigned int seconds_remaining);
+int go_handheld_ui_pick_provider(GoHandheldUi* ui);
+void go_handheld_ui_set_provider(GoHandheldUi* ui, GoHandheldUiProvider provider);
int go_handheld_ui_wait(GoHandheldUi* ui, Uint32 milliseconds);
int go_handheld_ui_cancel_requested(GoHandheldUi* ui);
+int go_handheld_ui_ad_action(GoHandheldUi* ui);
+void go_handheld_ui_draw_ad_hint(GoHandheldUi* ui, int modal);
+int go_handheld_ui_quit_requested(const GoHandheldUi* ui);
int go_handheld_ui_sign_in_action(GoHandheldUi* ui);
int go_handheld_ui_wait_for_retry(GoHandheldUi* ui, const char* heading, const char* detail);
+void go_handheld_ui_show_error(GoHandheldUi* ui, const char* heading, const char* detail);
int go_handheld_ui_pick_title(GoHandheldUi* ui, const GoCatalogTitle* titles, int count,
const char* requested);
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);
+void go_handheld_ui_draw_stream_controls(GoHandheldUi* ui, int mouse_mode, float pointer_x,
+ float pointer_y, int source_width, int source_height,
+ int show_hint);
#ifdef __cplusplus
}
diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig
index 1348151..39fb6c0 100644
--- a/src/ui/handheld_ui.zig
+++ b/src/ui/handheld_ui.zig
@@ -5,9 +5,12 @@ const keyboard = @import("keyboard.zig");
const library = @import("library_view.zig");
const navigation = @import("navigation_repeat.zig");
const persistent = @import("persistent_settings.zig");
+const provider_picker = @import("provider_picker.zig");
+const provider_badge = @import("provider_badge.zig");
const font = @import("pixel_font.zig");
const settings_view = @import("settings_view.zig");
const stream_dimensions = @import("stream_dimensions.zig");
+const stream_controls = @import("stream_controls.zig");
const style = @import("view_style.zig");
const c = @cImport({
@@ -26,11 +29,18 @@ const Ui = struct {
stop_context: ?*anyopaque,
settings: persistent.Store,
artwork: artwork.Loader = .{},
+ provider: provider_badge.Provider = .xbox,
cancelled: bool = false,
+ quit_requested: bool = false,
stream_width: u32,
stream_height: u32,
};
+pub export fn go_handheld_ui_draw_stream_controls(ui: ?*Ui, mouse_mode: c_int, x: f32, y: f32, width: c_int, height: c_int, show_hint: c_int) void {
+ const value = ui orelse return;
+ stream_controls.draw(value.renderer, value.settings.face_buttons, mouse_mode != 0, x, y, width, height, show_hint != 0);
+}
+
const ArtworkSelection = struct {
title_index: ?usize = null,
requested_index: ?usize = null,
@@ -73,9 +83,14 @@ fn cancelRequested(ui: *Ui) bool {
while (c.SDL_PollEvent(&event) != 0) {
c.go_controller_input_handle_event(ui.controller, &event);
if (event.type == c.SDL_QUIT or
- (event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE) or
- (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and
- semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_B))
+ (event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE))
+ {
+ if (event.type == c.SDL_QUIT) ui.quit_requested = true;
+ ui.cancelled = true;
+ return true;
+ }
+ if (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and
+ semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_B)
{
ui.cancelled = true;
return true;
@@ -128,6 +143,7 @@ fn signInAction(ui: *Ui) c_int {
var event: c.SDL_Event = undefined;
while (c.SDL_PollEvent(&event) != 0) {
c.go_controller_input_handle_event(ui.controller, &event);
+ if (event.type == c.SDL_QUIT) ui.quit_requested = true;
if (event.type == c.SDL_QUIT or
(event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE) or
(event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and
@@ -136,8 +152,9 @@ fn signInAction(ui: *Ui) c_int {
ui.cancelled = true;
return -1;
}
- if (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and
- semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_A) return 1;
+ if ((event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_RETURN) or
+ (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and
+ semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_A)) return 1;
}
if (shouldStop(ui)) {
ui.cancelled = true;
@@ -218,7 +235,7 @@ fn drawKeyboard(
for (keyboard.rows, 0..) |keys, row| {
const row_width = keyboardRowWidth(keys);
var x = @divTrunc(style.display_width - row_width, 2);
- const y: c_int = @intCast(174 + row * 52);
+ const y: c_int = @intCast(158 + row * 46);
for (keys, 0..) |key, column| {
const width = @as(c_int, key.width_units) * keyboard.unit_width;
drawKey(ui.renderer, x, y, width, key, row == selection.row and column == selection.column);
@@ -462,6 +479,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in
ui.renderer,
ui.controller,
&ui.settings,
+ ui.provider,
ui.stop_requested,
ui.stop_context,
);
@@ -470,6 +488,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in
return c.GO_HANDHELD_UI_PICK_CANCELLED;
}
if (result == .sign_out) return c.GO_HANDHELD_UI_PICK_SIGN_OUT;
+ if (result == .switch_provider) return c.GO_HANDHELD_UI_PICK_CHANGE_PROVIDER;
view.rebuild(&ui.settings, view.selectedTitleIndex());
dirty = true;
},
@@ -526,7 +545,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in
dirty = true;
}
if (dirty) {
- library.draw(ui.renderer, &view, &ui.settings, artwork_texture);
+ library.draw(ui.renderer, &view, &ui.settings, artwork_texture, ui.provider);
dirty = false;
}
c.SDL_Delay(16);
@@ -582,9 +601,20 @@ pub export fn go_handheld_ui_draw_device_code(
user_code: [*c]const u8,
status: [*c]const u8,
seconds_remaining: c_uint,
+) void {
+ go_handheld_ui_draw_device_code_for(ui, "XBOX SIGN IN", "MICROSOFT.COM/LINK", user_code, status, seconds_remaining);
+}
+
+pub export fn go_handheld_ui_draw_device_code_for(
+ ui: ?*Ui,
+ service: [*c]const u8,
+ address: [*c]const u8,
+ user_code: [*c]const u8,
+ status: [*c]const u8,
+ seconds_remaining: c_uint,
) void {
const handle = ui orelse return;
- if (user_code == null or status == null) return;
+ if (service == null or address == null or user_code == null or status == null) return;
style.setColor(handle.renderer, style.background());
_ = c.SDL_RenderClear(handle.renderer);
style.setColor(handle.renderer, style.panel());
@@ -596,13 +626,14 @@ pub export fn go_handheld_ui_draw_device_code(
_ = c.SDL_RenderFillRect(handle.renderer, &footer);
style.drawMark(handle.renderer);
font.text(handle.renderer, 78, 12, 4, "GREENOVERCAST", style.bright());
- font.text(handle.renderer, 80, 46, 2, "XBOX SIGN IN", style.accent());
+ font.text(handle.renderer, 80, 46, 2, service, style.accent());
const heading = "SIGN IN ON ANOTHER DEVICE";
font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(heading, 3), 2), 96, 3, heading, style.bright());
const instruction = "OPEN THIS ADDRESS ON YOUR PHONE";
font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(instruction, 2), 2), 148, 2, instruction, style.muted());
- const address = "MICROSOFT.COM/LINK";
- font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(address, 3), 2), 180, 3, address, style.accent());
+ const address_scale: c_int = if (font.textWidth(address, 3) <= style.display_width - 40) 3 else 2;
+ const address_x = @max(20, @divTrunc(style.display_width - font.textWidth(address, address_scale), 2));
+ font.textEllipsized(handle.renderer, address_x, 180, address_scale, address, style.display_width - 40, style.accent());
const code_label = "ENTER THIS CODE";
font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(code_label, 2), 2), 244, 2, code_label, style.muted());
font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(user_code, 5), 2), 282, 5, user_code, style.bright());
@@ -618,6 +649,26 @@ pub export fn go_handheld_ui_draw_device_code(
c.SDL_RenderPresent(handle.renderer);
}
+pub export fn go_handheld_ui_pick_provider(ui: ?*Ui) c_int {
+ const handle = ui orelse return @intFromEnum(provider_picker.Result.cancelled);
+ handle.cancelled = false;
+ return @intFromEnum(provider_picker.run(
+ handle.renderer,
+ handle.controller,
+ handle.settings.face_buttons,
+ handle.stop_requested,
+ handle.stop_context,
+ ));
+}
+
+pub export fn go_handheld_ui_set_provider(ui: ?*Ui, provider: c.GoHandheldUiProvider) void {
+ const handle = ui orelse return;
+ handle.provider = switch (provider) {
+ c.GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW => .geforce_now,
+ else => .xbox,
+ };
+}
+
pub export fn go_handheld_ui_wait(ui: ?*Ui, milliseconds: c.Uint32) c_int {
const handle = ui orelse return -1;
const started = c.SDL_GetTicks();
@@ -632,6 +683,49 @@ pub export fn go_handheld_ui_cancel_requested(ui: ?*Ui) c_int {
return @intFromBool(cancelRequested(ui orelse return 1));
}
+pub export fn go_handheld_ui_ad_action(ui: ?*Ui) c_int {
+ const handle = ui orelse return -1;
+ if (shouldStop(handle)) return -1;
+ var result: c_int = 0;
+ var event: c.SDL_Event = undefined;
+ while (c.SDL_PollEvent(&event) != 0) {
+ c.go_controller_input_handle_event(handle.controller, &event);
+ if (event.type == c.SDL_QUIT) {
+ handle.quit_requested = true;
+ return -1;
+ }
+ if (event.type == c.SDL_KEYDOWN) {
+ if (event.key.keysym.sym == c.SDLK_ESCAPE) return 2;
+ if (event.key.keysym.sym == c.SDLK_RETURN) result = 1;
+ if (event.key.keysym.sym == c.SDLK_LEFT) result = 3;
+ if (event.key.keysym.sym == c.SDLK_RIGHT) result = 4;
+ }
+ if (event.type != c.SDL_CONTROLLERBUTTONDOWN or !activeControllerEvent(handle, &event)) continue;
+ switch (semanticButton(handle, event.cbutton.button)) {
+ c.SDL_CONTROLLER_BUTTON_B => return 2,
+ c.SDL_CONTROLLER_BUTTON_START => result = 1,
+ c.SDL_CONTROLLER_BUTTON_DPAD_LEFT => result = 3,
+ c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT => result = 4,
+ else => {},
+ }
+ }
+ return result;
+}
+
+pub export fn go_handheld_ui_draw_ad_hint(ui: ?*Ui, modal: c_int) void {
+ const handle = ui orelse return;
+ const prompts = [_]controls.Prompt{if (modal != 0)
+ controls.Prompt.one(controls.face(handle.settings.face_buttons, .b), "BACK TO AD")
+ else
+ controls.Prompt.one(.start, "AD INFO")};
+ controls.drawCenteredRow(handle.renderer, 439, &prompts, style.bright());
+}
+
+pub export fn go_handheld_ui_quit_requested(ui: ?*const Ui) c_int {
+ const handle = ui orelse return 1;
+ return @intFromBool(handle.quit_requested or shouldStop(handle));
+}
+
pub export fn go_handheld_ui_sign_in_action(ui: ?*Ui) c_int {
return signInAction(ui orelse return -1);
}
@@ -646,6 +740,29 @@ pub export fn go_handheld_ui_wait_for_retry(ui: ?*Ui, heading: [*c]const u8, det
}
}
+pub export fn go_handheld_ui_show_error(ui: ?*Ui, heading: [*c]const u8, detail: [*c]const u8) void {
+ const handle = ui orelse return;
+ drawLoading(handle, heading, detail, c.GO_HANDHELD_UI_ACTION_BACK);
+ while (true) {
+ var event: c.SDL_Event = undefined;
+ while (c.SDL_PollEvent(&event) != 0) {
+ c.go_controller_input_handle_event(handle.controller, &event);
+ if (event.type == c.SDL_QUIT) {
+ handle.quit_requested = true;
+ return;
+ }
+ if (event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE) return;
+ if (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(handle, &event) and
+ semanticButton(handle, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_B) return;
+ }
+ if (shouldStop(handle) or c.go_controller_input_exit_held(handle.controller, 1000) != 0) {
+ handle.quit_requested = true;
+ return;
+ }
+ c.SDL_Delay(16);
+ }
+}
+
pub export fn go_handheld_ui_pick_title(
ui: ?*Ui,
titles: [*c]const c.GoCatalogTitle,
diff --git a/src/ui/keyboard.zig b/src/ui/keyboard.zig
index aede765..a4c0e77 100644
--- a/src/ui/keyboard.zig
+++ b/src/ui/keyboard.zig
@@ -47,7 +47,6 @@ const row_letters_3 = [_]Key{
.{ .label = "B", .action = .{ .character = 'B' } },
.{ .label = "N", .action = .{ .character = 'N' } },
.{ .label = "M", .action = .{ .character = 'M' } },
- .{ .label = "SPACE", .action = .space, .width_units = 3 },
};
const row_numbers = [_]Key{
@@ -63,11 +62,16 @@ const row_numbers = [_]Key{
.{ .label = "0", .action = .{ .character = '0' } },
};
+const row_space = [_]Key{
+ .{ .label = "SPACE", .action = .space, .width_units = 5 },
+};
+
pub const rows = [_][]const Key{
+ &row_numbers,
&row_letters_1,
&row_letters_2,
&row_letters_3,
- &row_numbers,
+ &row_space,
};
pub const Selection = struct {
@@ -148,9 +152,9 @@ pub fn clear(query: []u8) void {
test "space key inserts a space" {
var query = [_]u8{0} ** 16;
@memcpy(query[0..6], "HOLLOW");
- var selection = Selection{ .row = 2, .column = 7 };
+ var selection = Selection{ .row = 4, .column = 0 };
activate(selection, &query);
- selection = .{ .row = 2, .column = 5 };
+ selection = .{ .row = 3, .column = 5 };
activate(selection, &query);
try std.testing.expectEqualStrings("HOLLOW N", std.mem.sliceTo(&query, 0));
}
@@ -160,17 +164,17 @@ test "selection wraps and stays within the next row" {
selection.moveHorizontal(-1);
try std.testing.expectEqual(@as(usize, 9), selection.column);
selection.moveVertical(1);
- try std.testing.expectEqual(@as(usize, 8), selection.column);
+ try std.testing.expectEqual(@as(usize, 9), selection.column);
selection.moveVertical(1);
- try std.testing.expectEqual(@as(usize, 7), selection.column);
+ try std.testing.expectEqual(@as(usize, 8), selection.column);
}
test "vertical movement follows the visual center of the space key" {
- var selection = Selection{ .row = 2, .column = 7 };
- selection.moveVertical(1);
- try std.testing.expectEqual(@as(usize, 3), selection.row);
- try std.testing.expectEqual(@as(usize, 8), selection.column);
+ var selection = Selection{ .row = 4, .column = 0 };
selection.moveVertical(-1);
- try std.testing.expectEqual(@as(usize, 2), selection.row);
- try std.testing.expectEqual(@as(usize, 7), selection.column);
+ try std.testing.expectEqual(@as(usize, 3), selection.row);
+ try std.testing.expectEqual(@as(usize, 3), selection.column);
+ selection.moveVertical(1);
+ try std.testing.expectEqual(@as(usize, 4), selection.row);
+ try std.testing.expectEqual(@as(usize, 0), selection.column);
}
diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig
index a01ecf8..a1be432 100644
--- a/src/ui/library_view.zig
+++ b/src/ui/library_view.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const controls = @import("control_icons.zig");
const search = @import("catalog_search");
const font = @import("pixel_font.zig");
+const provider_badge = @import("provider_badge.zig");
const settings = @import("persistent_settings.zig");
const style = @import("view_style.zig");
@@ -136,20 +137,20 @@ pub fn draw(
view: *const View,
store: *const settings.Store,
artwork_pointer: ?*anyopaque,
+ provider: provider_badge.Provider,
) void {
const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer));
const artwork: ?*c.SDL_Texture = if (artwork_pointer) |value| @ptrCast(@alignCast(value)) else null;
style.setColor(renderer, style.background());
_ = c.SDL_RenderClear(renderer);
style.setColor(renderer, style.panel());
- var header = c.SDL_Rect{ .x = 0, .y = 0, .w = style.display_width, .h = 78 };
+ var header = c.SDL_Rect{ .x = 0, .y = 0, .w = style.display_width, .h = 62 };
var footer = c.SDL_Rect{ .x = 0, .y = 424, .w = style.display_width, .h = 56 };
_ = c.SDL_RenderFillRect(renderer, &header);
_ = c.SDL_RenderFillRect(renderer, &footer);
style.drawMark(renderer);
- font.text(renderer, 78, 12, 4, "GREENOVERCAST", style.bright());
-
drawTabs(renderer, view.collection);
+ provider_badge.draw(renderer, provider);
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());
@@ -177,7 +178,7 @@ pub fn draw(
const index = start + row;
if (index >= view.count) break;
const title = &view.titles[view.indices[index]];
- const y: c_int = @intCast(86 + row * 36);
+ const y: c_int = @intCast(70 + row * 38);
if (index == view.selected) {
style.setColor(renderer, style.selection());
var selection = c.SDL_Rect{ .x = 14, .y = y, .w = list_right - 14, .h = 32 };
@@ -223,16 +224,16 @@ pub fn draw(
fn drawTabs(renderer: *c.SDL_Renderer, active: Collection) void {
const labels = [_]struct { Collection, [*:0]const u8, c_int }{
- .{ .all, "ALL", 210 },
- .{ .favorites, "FAVORITES", 300 },
+ .{ .all, "ALL", 84 },
+ .{ .favorites, "FAVORITES", 162 },
};
for (labels) |entry| {
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 };
+ var rect = c.SDL_Rect{ .x = entry[2] - 10, .y = 16, .w = font.textWidth(entry[1], 2) + 20, .h = 28 };
_ = c.SDL_RenderFillRect(renderer, &rect);
}
- font.text(renderer, entry[2], 52, 2, entry[1], if (entry[0] == active) style.bright() else style.muted());
+ font.text(renderer, entry[2], 22, 2, entry[1], if (entry[0] == active) style.bright() else style.muted());
}
}
diff --git a/src/ui/provider_badge.zig b/src/ui/provider_badge.zig
new file mode 100644
index 0000000..33a1b39
--- /dev/null
+++ b/src/ui/provider_badge.zig
@@ -0,0 +1,55 @@
+const font = @import("pixel_font.zig");
+const style = @import("view_style.zig");
+
+const c = @cImport({
+ @cInclude("SDL2/SDL.h");
+});
+
+pub const Provider = enum {
+ xbox,
+ geforce_now,
+};
+
+// Bootstrap Icons, rasterized at 24x24; source revision in vendor/manifest.lock.
+const xbox_mark = [24]u24{
+ 0x007e00, 0x01ff80, 0x003c00, 0x000000, 0x1c0038, 0x3f007c,
+ 0x7f00fe, 0x7f00fe, 0x7e007e, 0xfe003f, 0xfc183f, 0xf83c1f,
+ 0xf87e1f, 0xf0ff0f, 0xf1ff8f, 0x63ffc6, 0x67ffe6, 0x67ffe2,
+ 0x0ffff0, 0x0ffff0, 0x0ffff0, 0x07ffe0, 0x03ffc0, 0x007e00,
+};
+
+const nvidia_mark = [24]u24{
+ 0x000000, 0x000000, 0x000000, 0x007fff, 0x007fff, 0x007fff,
+ 0x0187ff, 0x0e71ff, 0x187cff, 0x73867f, 0xe6473f, 0xe4667f,
+ 0x667cef, 0x3379c7, 0x198787, 0x0c7e0f, 0x07707f, 0x0187ff,
+ 0x007fff, 0x007fff, 0x007fff, 0x000000, 0x000000, 0x000000,
+};
+
+pub fn draw(renderer_pointer: *anyopaque, provider: Provider) void {
+ const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer));
+ const label: [*:0]const u8 = if (provider == .xbox) "XBOX" else "GFN";
+ const label_width = font.textWidth(label, 2);
+ const width = 32 + label_width;
+ const x = style.display_width - width - 16;
+
+ drawMark(renderer, provider, x, 15);
+ font.text(renderer, x + 32, 20, 2, label, style.bright());
+}
+
+fn drawMark(renderer: *c.SDL_Renderer, provider: Provider, x: c_int, y: c_int) void {
+ const rows = if (provider == .xbox) &xbox_mark else &nvidia_mark;
+ style.setColor(renderer, if (provider == .xbox) style.bright() else .{ .r = 118, .g = 185, .b = 0, .a = 255 });
+ for (rows, 0..) |row, dy| {
+ var column: u5 = 0;
+ while (column < 24) {
+ if (row & (@as(u24, 1) << (23 - column)) == 0) {
+ column += 1;
+ continue;
+ }
+ const start = column;
+ while (column < 24 and row & (@as(u24, 1) << (23 - column)) != 0) : (column += 1) {}
+ var rect = c.SDL_Rect{ .x = x + start, .y = y + @as(c_int, @intCast(dy)), .w = column - start, .h = 1 };
+ _ = c.SDL_RenderFillRect(renderer, &rect);
+ }
+ }
+}
diff --git a/src/ui/provider_picker.zig b/src/ui/provider_picker.zig
new file mode 100644
index 0000000..4306c4b
--- /dev/null
+++ b/src/ui/provider_picker.zig
@@ -0,0 +1,143 @@
+const controls = @import("control_icons.zig");
+const font = @import("pixel_font.zig");
+const settings = @import("persistent_settings.zig");
+const style = @import("view_style.zig");
+
+const c = @cImport({
+ @cInclude("SDL2/SDL.h");
+ @cInclude("controller.h");
+});
+
+pub const Result = enum(c_int) {
+ cancelled = -1,
+ xbox = 0,
+ geforce_now = 1,
+};
+
+const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int;
+
+pub fn run(
+ renderer_pointer: *anyopaque,
+ controller_pointer: *anyopaque,
+ face_buttons: settings.FaceButtonMode,
+ stop_requested: StopRequested,
+ stop_context: ?*anyopaque,
+) Result {
+ const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer));
+ const controller: *c.GoControllerInput = @ptrCast(@alignCast(controller_pointer));
+ var selection = Result.xbox;
+ var dirty = true;
+ var input_armed = false;
+ const settle_started = c.SDL_GetTicks();
+ while (true) {
+ var event: c.SDL_Event = undefined;
+ while (c.SDL_PollEvent(&event) != 0) {
+ c.go_controller_input_handle_event(controller, &event);
+ if (event.type == c.SDL_QUIT) return .cancelled;
+ if (!input_armed) continue;
+ if (event.type == c.SDL_KEYDOWN) switch (event.key.keysym.sym) {
+ c.SDLK_ESCAPE => return .cancelled,
+ c.SDLK_UP, c.SDLK_DOWN => selection = toggle(selection),
+ c.SDLK_RETURN => return selection,
+ else => {},
+ };
+ if (event.type == c.SDL_KEYDOWN) dirty = true;
+ if (event.type == c.SDL_CONTROLLERBUTTONDOWN and
+ c.go_controller_input_event_is_active(controller, &event) != 0)
+ {
+ const button = c.go_controller_input_map_button(controller, event.cbutton.button);
+ switch (button) {
+ c.SDL_CONTROLLER_BUTTON_B => return .cancelled,
+ c.SDL_CONTROLLER_BUTTON_A => return selection,
+ c.SDL_CONTROLLER_BUTTON_DPAD_UP, c.SDL_CONTROLLER_BUTTON_DPAD_DOWN => {
+ selection = toggle(selection);
+ dirty = true;
+ },
+ else => {},
+ }
+ }
+ }
+ if (!input_armed and c.SDL_GetTicks() -% settle_started >= 500 and
+ inputNeutral(controller)) input_armed = true;
+ if (shouldStop(stop_requested, stop_context) or
+ c.go_controller_input_exit_held(controller, 1000) != 0) return .cancelled;
+ if (dirty) {
+ draw(renderer, face_buttons, selection);
+ dirty = false;
+ }
+ c.SDL_Delay(16);
+ }
+}
+
+fn inputNeutral(controller: *c.GoControllerInput) bool {
+ const buttons = [_]c.SDL_GameControllerButton{
+ c.SDL_CONTROLLER_BUTTON_A,
+ c.SDL_CONTROLLER_BUTTON_B,
+ c.SDL_CONTROLLER_BUTTON_DPAD_UP,
+ c.SDL_CONTROLLER_BUTTON_DPAD_DOWN,
+ };
+ for (buttons) |button| {
+ if (c.go_controller_input_button_pressed(controller, button) != 0) return false;
+ }
+ const keys = c.SDL_GetKeyboardState(null);
+ if (keys[c.SDL_SCANCODE_RETURN] != 0 or
+ keys[c.SDL_SCANCODE_ESCAPE] != 0 or
+ keys[c.SDL_SCANCODE_UP] != 0 or
+ keys[c.SDL_SCANCODE_DOWN] != 0) return false;
+ return true;
+}
+
+fn toggle(current: Result) Result {
+ return if (current == .xbox) .geforce_now else .xbox;
+}
+
+fn draw(
+ renderer: *c.SDL_Renderer,
+ face_buttons: settings.FaceButtonMode,
+ selection: Result,
+) void {
+ style.setColor(renderer, style.background());
+ _ = c.SDL_RenderClear(renderer);
+ style.setColor(renderer, style.panel());
+ var header = c.SDL_Rect{ .x = 0, .y = 0, .w = style.display_width, .h = 74 };
+ var footer = c.SDL_Rect{ .x = 0, .y = 424, .w = style.display_width, .h = 56 };
+ _ = c.SDL_RenderFillRect(renderer, &header);
+ _ = c.SDL_RenderFillRect(renderer, &footer);
+ style.drawMark(renderer);
+ font.text(renderer, 78, 12, 4, "GREENOVERCAST", style.bright());
+ font.text(renderer, 78, 50, 2, "CHOOSE A STREAMING SERVICE", style.accent());
+
+ drawChoice(renderer, 118, "XBOX CLOUD GAMING", "GAME PASS LIBRARY", selection == .xbox);
+ drawChoice(renderer, 242, "GEFORCE NOW", "PC GAME STREAMING", selection == .geforce_now);
+
+ const prompts = [_]controls.Prompt{
+ controls.Prompt.one(controls.face(face_buttons, .a), "SELECT"),
+ controls.Prompt.one(.dpad, "MOVE"),
+ controls.Prompt.one(controls.face(face_buttons, .b), "EXIT"),
+ };
+ controls.drawCenteredRow(renderer, 439, &prompts, style.bright());
+ c.SDL_RenderPresent(renderer);
+}
+
+fn drawChoice(
+ renderer: *c.SDL_Renderer,
+ y: c_int,
+ title: [*:0]const u8,
+ detail: [*:0]const u8,
+ selected: bool,
+) void {
+ style.setColor(renderer, if (selected) style.selection() else style.panel());
+ var panel = c.SDL_Rect{ .x = 54, .y = y, .w = 532, .h = 96 };
+ _ = c.SDL_RenderFillRect(renderer, &panel);
+ if (selected) {
+ style.setColor(renderer, style.accent());
+ var bar = c.SDL_Rect{ .x = 54, .y = y, .w = 6, .h = 96 };
+ _ = c.SDL_RenderFillRect(renderer, &bar);
+ }
+ font.text(renderer, 84, y + 18, 3, title, if (selected) style.bright() else style.muted());
+ font.text(renderer, 84, y + 58, 2, detail, if (selected) style.accent() else style.muted());
+}
+
+fn shouldStop(callback: StopRequested, context: ?*anyopaque) bool {
+ return if (callback) |stop| stop(context) != 0 else false;
+}
diff --git a/src/ui/settings_view.zig b/src/ui/settings_view.zig
index c669f67..74eb580 100644
--- a/src/ui/settings_view.zig
+++ b/src/ui/settings_view.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const controls = @import("control_icons.zig");
const navigation = @import("navigation_repeat.zig");
const font = @import("pixel_font.zig");
+const provider_badge = @import("provider_badge.zig");
const settings = @import("persistent_settings.zig");
const style = @import("view_style.zig");
@@ -12,6 +13,7 @@ const c = @cImport({
pub const Result = enum {
back,
+ switch_provider,
sign_out,
cancelled,
};
@@ -21,6 +23,13 @@ const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int;
const Row = enum {
face_buttons,
artwork,
+ service,
+ sign_out,
+};
+
+const Action = enum {
+ none,
+ switch_provider,
sign_out,
};
@@ -28,6 +37,7 @@ pub fn run(
renderer_pointer: *anyopaque,
controller_pointer: *anyopaque,
store: *settings.Store,
+ provider: provider_badge.Provider,
stop_requested: StopRequested,
stop_context: ?*anyopaque,
) Result {
@@ -46,9 +56,10 @@ pub fn run(
c.SDLK_ESCAPE => return .back,
c.SDLK_UP => selected = previousRow(selected),
c.SDLK_DOWN => selected = nextRow(selected),
- c.SDLK_RETURN => if (activate(selected, controller, store)) {
- const confirmation = confirmSignOut(renderer, controller, store.face_buttons, stop_requested, stop_context);
- if (confirmation != .back) return confirmation;
+ c.SDLK_RETURN => {
+ const action = activate(selected, controller, store);
+ if (resolveAction(action, renderer, controller, store.face_buttons, stop_requested, stop_context)) |result|
+ return result;
dirty = true;
},
else => {},
@@ -60,9 +71,10 @@ pub fn run(
const button = c.go_controller_input_map_button(controller, event.cbutton.button);
switch (button) {
c.SDL_CONTROLLER_BUTTON_B => return .back,
- c.SDL_CONTROLLER_BUTTON_A => if (activate(selected, controller, store)) {
- const confirmation = confirmSignOut(renderer, controller, store.face_buttons, stop_requested, stop_context);
- if (confirmation != .back) return confirmation;
+ c.SDL_CONTROLLER_BUTTON_A => {
+ const action = activate(selected, controller, store);
+ if (resolveAction(action, renderer, controller, store.face_buttons, stop_requested, stop_context)) |result|
+ return result;
dirty = true;
},
c.SDL_CONTROLLER_BUTTON_DPAD_UP => {
@@ -74,7 +86,8 @@ pub fn run(
repeat.begin(.down, c.SDL_GetTicks());
},
c.SDL_CONTROLLER_BUTTON_DPAD_LEFT, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT => {
- if (selected != .sign_out) _ = activate(selected, controller, store);
+ if (selected == .face_buttons or selected == .artwork)
+ _ = activate(selected, controller, store);
},
else => {},
}
@@ -96,7 +109,7 @@ pub fn run(
}
}
if (dirty) {
- draw(renderer, store, selected);
+ draw(renderer, store, provider, selected);
dirty = false;
}
c.SDL_Delay(16);
@@ -107,7 +120,7 @@ fn activate(
row: Row,
controller: *c.GoControllerInput,
store: *settings.Store,
-) bool {
+) Action {
switch (row) {
.face_buttons => {
store.face_buttons = if (store.face_buttons == .system) .swapped else .system;
@@ -117,24 +130,51 @@ fn activate(
);
},
.artwork => store.artwork_enabled = !store.artwork_enabled,
- .sign_out => return true,
+ .service => return .switch_provider,
+ .sign_out => return .sign_out,
}
store.save() catch std.debug.print("Settings could not be saved\n", .{});
- return false;
+ return .none;
+}
+
+fn resolveAction(
+ action: Action,
+ renderer: *c.SDL_Renderer,
+ controller: *c.GoControllerInput,
+ face_buttons: settings.FaceButtonMode,
+ stop_requested: StopRequested,
+ stop_context: ?*anyopaque,
+) ?Result {
+ return switch (action) {
+ .none => null,
+ .switch_provider => .switch_provider,
+ .sign_out => result: {
+ const confirmation = confirmSignOut(
+ renderer,
+ controller,
+ face_buttons,
+ stop_requested,
+ stop_context,
+ );
+ break :result if (confirmation == .back) null else confirmation;
+ },
+ };
}
fn previousRow(row: Row) Row {
return switch (row) {
.face_buttons => .sign_out,
.artwork => .face_buttons,
- .sign_out => .artwork,
+ .service => .artwork,
+ .sign_out => .service,
};
}
fn nextRow(row: Row) Row {
return switch (row) {
.face_buttons => .artwork,
- .artwork => .sign_out,
+ .artwork => .service,
+ .service => .sign_out,
.sign_out => .face_buttons,
};
}
@@ -152,6 +192,7 @@ fn heldDirection(controller: *c.GoControllerInput, latch: *navigation.AxisLatch)
fn draw(
renderer: *c.SDL_Renderer,
store: *const settings.Store,
+ provider: provider_badge.Provider,
selected: Row,
) void {
style.setColor(renderer, style.background());
@@ -163,9 +204,10 @@ fn draw(
_ = c.SDL_RenderFillRect(renderer, &footer);
font.text(renderer, 18, 14, 4, "SETTINGS", style.bright());
- 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, 82, "FACE BUTTONS", if (store.face_buttons == .system) "SYSTEM" else "SWAPPED", selected == .face_buttons);
+ drawRow(renderer, 130, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork);
+ drawRow(renderer, 178, "STREAMING SERVICE", if (provider == .xbox) "XBOX" else "GEFORCE NOW", selected == .service);
+ drawRow(renderer, 226, "ACCOUNT", "SIGN OUT", selected == .sign_out);
drawMappingExplanation(renderer, store.face_buttons);
font.text(renderer, 18, 340, 2, "USE SWAPPED ONLY IF BUTTONS ARE REVERSED", style.muted());
@@ -222,7 +264,7 @@ fn confirmSignOut(
style.setColor(renderer, style.background());
_ = c.SDL_RenderClear(renderer);
font.text(renderer, 164, 174, 4, "SIGN OUT?", style.bright());
- font.text(renderer, 110, 242, 2, "YOU WILL NEED TO LINK XBOX AGAIN", style.muted());
+ font.text(renderer, 128, 242, 2, "YOU WILL NEED TO SIGN IN AGAIN", style.muted());
const prompts = [_]controls.Prompt{
controls.Prompt.one(controls.face(face_buttons, .a), "YES"),
controls.Prompt.one(controls.face(face_buttons, .b), "NO"),
diff --git a/src/ui/stream_controls.zig b/src/ui/stream_controls.zig
new file mode 100644
index 0000000..54a912a
--- /dev/null
+++ b/src/ui/stream_controls.zig
@@ -0,0 +1,53 @@
+const controls = @import("control_icons.zig");
+const settings = @import("persistent_settings.zig");
+const style = @import("view_style.zig");
+const c = @cImport({
+ @cInclude("SDL2/SDL.h");
+});
+
+pub fn draw(renderer_pointer: *anyopaque, face_buttons: settings.FaceButtonMode, mouse_mode: bool, x: f32, y: f32, source_width: c_int, source_height: c_int, show_hint: bool) void {
+ const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer));
+ var width: c_int = 0;
+ var height: c_int = 0;
+ if (c.SDL_GetRendererOutputSize(renderer, &width, &height) != 0 or width <= 0 or height <= 0) return;
+ var old_x: f32 = 1;
+ var old_y: f32 = 1;
+ c.SDL_RenderGetScale(renderer, &old_x, &old_y);
+ defer _ = c.SDL_RenderSetScale(renderer, old_x, old_y);
+ _ = c.SDL_RenderSetScale(renderer, @as(f32, @floatFromInt(width)) / 640, @as(f32, @floatFromInt(height)) / 480);
+ if (mouse_mode or show_hint) {
+ var band = c.SDL_Rect{ .x = 0, .y = 0, .w = 640, .h = 28 };
+ style.setColor(renderer, style.panel());
+ _ = c.SDL_RenderFillRect(renderer, &band);
+ controls.drawCenteredRow(renderer, 4, &.{controls.Prompt.two(.select, controls.face(face_buttons, .y), if (mouse_mode) "GAMEPAD MODE" else "MOUSE MODE")}, style.bright());
+ }
+ if (!mouse_mode) return;
+ var band = c.SDL_Rect{ .x = 0, .y = 452, .w = 640, .h = 28 };
+ style.setColor(renderer, style.panel());
+ _ = c.SDL_RenderFillRect(renderer, &band);
+ controls.drawCenteredRow(renderer, 456, &.{
+ controls.Prompt.one(.left_stick, "MOVE"),
+ controls.Prompt.one(controls.face(face_buttons, .a), "CLICK"),
+ controls.Prompt.one(controls.face(face_buttons, .b), "RIGHT"),
+ controls.Prompt.two(.left_bumper, .right_bumper, "SCROLL"),
+ }, style.bright());
+ if (source_width <= 0 or source_height <= 0) return;
+ var w: f32 = 640;
+ var h: f32 = 480;
+ if (@as(i64, width) * source_height > @as(i64, height) * source_width)
+ w = 640 * (@as(f32, @floatFromInt(height)) * @as(f32, @floatFromInt(source_width))) / (@as(f32, @floatFromInt(width)) * @as(f32, @floatFromInt(source_height)))
+ else
+ h = 480 * (@as(f32, @floatFromInt(width)) * @as(f32, @floatFromInt(source_height))) / (@as(f32, @floatFromInt(height)) * @as(f32, @floatFromInt(source_width)));
+ const px: c_int = @intFromFloat((640 - w) / 2 + x * (w - 1));
+ const py: c_int = @intFromFloat((480 - h) / 2 + y * (h - 1));
+ style.setColor(renderer, .{ .r = 0, .g = 0, .b = 0, .a = 255 });
+ for (0..14) |row| {
+ var line = c.SDL_Rect{ .x = px, .y = py + @as(c_int, @intCast(row)), .w = @intCast(@min(row + 1, 9)), .h = 1 };
+ _ = c.SDL_RenderFillRect(renderer, &line);
+ }
+ style.setColor(renderer, style.bright());
+ for (2..12) |row| {
+ var line = c.SDL_Rect{ .x = px + 1, .y = py + @as(c_int, @intCast(row)), .w = @intCast(@min(row - 1, 6)), .h = 1 };
+ _ = c.SDL_RenderFillRect(renderer, &line);
+ }
+}
diff --git a/src/util/uuid.zig b/src/util/uuid.zig
new file mode 100644
index 0000000..4cf462f
--- /dev/null
+++ b/src/util/uuid.zig
@@ -0,0 +1,47 @@
+const std = @import("std");
+
+pub const string_length = 36;
+
+pub fn generate(output: *[string_length + 1]u8) void {
+ var bytes: [16]u8 = undefined;
+ std.crypto.random.bytes(&bytes);
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ write(output, bytes);
+}
+
+pub fn valid(value: []const u8) bool {
+ if (value.len != string_length) return false;
+ for (value, 0..) |byte, index| {
+ if (index == 8 or index == 13 or index == 18 or index == 23) {
+ if (byte != '-') return false;
+ } else if (!std.ascii.isHex(byte)) return false;
+ }
+ return value[14] == '4' and (value[19] == '8' or value[19] == '9' or
+ std.ascii.toLower(value[19]) == 'a' or std.ascii.toLower(value[19]) == 'b');
+}
+
+fn write(output: *[string_length + 1]u8, bytes: [16]u8) void {
+ _ = std.fmt.bufPrintZ(
+ output,
+ "{x:0>2}{x:0>2}{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-" ++
+ "{x:0>2}{x:0>2}-{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}",
+ .{
+ bytes[0], bytes[1], bytes[2], bytes[3],
+ bytes[4], bytes[5], bytes[6], bytes[7],
+ bytes[8], bytes[9], bytes[10], bytes[11],
+ bytes[12], bytes[13], bytes[14], bytes[15],
+ },
+ ) catch unreachable;
+}
+
+test "generates a valid version four UUID" {
+ var output: [string_length + 1]u8 = undefined;
+ generate(&output);
+ try std.testing.expect(valid(std.mem.sliceTo(&output, 0)));
+}
+
+test "rejects malformed UUIDs" {
+ try std.testing.expect(!valid("12345678-1234-4abc-8def"));
+ try std.testing.expect(!valid("12345678-1234-1abc-8def-123456789abc"));
+}
diff --git a/tests/aarch64_runtime_test.sh b/tests/aarch64_runtime_test.sh
new file mode 100755
index 0000000..09bf80a
--- /dev/null
+++ b/tests/aarch64_runtime_test.sh
@@ -0,0 +1,116 @@
+#!/bin/sh
+set -eu
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+ZIG="$ROOT/.tools/zig-0.14.1/zig"
+CHECK="$ROOT/tools/check-aarch64-runtime.sh"
+TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/greenovercast-atomics-test.XXXXXX")
+trap 'rm -rf "$TEST_ROOT"' EXIT HUP INT TERM
+reject() {
+ expected=$1
+ shift
+ if "$@" >"$TEST_ROOT/rejected.log" 2>&1; then
+ echo "unexpected success: $*" >&2
+ exit 1
+ fi
+ if ! grep -F "$expected" "$TEST_ROOT/rejected.log" >/dev/null; then
+ cat "$TEST_ROOT/rejected.log" >&2
+ echo "missing diagnostic: $expected" >&2
+ exit 1
+ fi
+}
+shared() {
+ "$ZIG" build-lib -target "${3:-aarch64-linux-gnu.2.38}" -dynamic \
+ -fno-compiler-rt -fallow-shlib-undefined "$1" -femit-bin="$2"
+}
+fixture="$TEST_ROOT/project"
+mkdir -p "$fixture/tools" "$fixture/.tools/zig-0.14.1"
+cp "$ROOT/tools/build-dependencies.sh" "$fixture/tools/"
+ln -s "$ZIG" "$fixture/.tools/zig-0.14.1/zig"
+sh "$fixture/tools/build-dependencies.sh" --toolchain-only
+cat >"$TEST_ROOT/atomic.c" <<'C'
+unsigned long long atomic_add(unsigned long long *value) {
+ return __atomic_fetch_add(value, 1ULL, __ATOMIC_ACQ_REL);
+}
+C
+"$ZIG" cc -target aarch64-linux-gnu.2.38 -mcpu=baseline \
+ -O2 -fPIC -moutline-atomics -c "$TEST_ROOT/atomic.c" -o "$TEST_ROOT/outlined.o"
+shared "$TEST_ROOT/outlined.o" "$TEST_ROOT/outlined.so"
+reject '__aarch64_ldadd8_acq_rel' sh "$CHECK" "$TEST_ROOT/outlined.so"
+echo 'PASS: outlined ARM64 C artifact rejected with the reported symbol'
+for driver in cc c++; do
+ language=c
+ [ "$driver" != "c++" ] || language=c++
+ "$fixture/.tools/cross/aarch64-linux-gnu/$driver" -O2 -fPIC \
+ -moutline-atomics -x "$language" -c "$TEST_ROOT/atomic.c" -o "$TEST_ROOT/inline.o"
+ shared "$TEST_ROOT/inline.o" "$TEST_ROOT/inline.so"
+ sh "$CHECK" "$TEST_ROOT/inline.so"
+ echo "PASS: $driver wrapper prevents caller flags from re-enabling outline atomics"
+done
+"$ZIG" build-obj -target aarch64-linux-gnu.2.38 -mcpu=baseline-outline_atomics \
+ -O ReleaseSafe -fPIC -cflags -mno-outline-atomics -- "$TEST_ROOT/atomic.c" \
+ -femit-bin="$TEST_ROOT/build-obj.o"
+shared "$TEST_ROOT/build-obj.o" "$TEST_ROOT/build-obj.so"
+sh "$CHECK" "$TEST_ROOT/build-obj.so"
+echo 'PASS: Zig C-source flags prevent Clang from re-enabling outline atomics'
+cat >"$TEST_ROOT/atomic.zig" <<'ZIG'
+export fn atomic_add(value: *u64) u64 {
+ return @atomicRmw(u64, value, .Add, 1, .acq_rel);
+}
+ZIG
+for policy in +outline_atomics -outline_atomics; do
+ "$ZIG" build-obj -target aarch64-linux-gnu.2.38 -mcpu="baseline$policy" \
+ -O ReleaseSafe -fPIC "$TEST_ROOT/atomic.zig" -femit-bin="$TEST_ROOT/zig.o"
+ shared "$TEST_ROOT/zig.o" "$TEST_ROOT/zig.so"
+ case "$policy" in
+ +*) reject '__aarch64_ldadd8_acq_rel' sh "$CHECK" "$TEST_ROOT/zig.so" ;;
+ -*) sh "$CHECK" "$TEST_ROOT/zig.so" ;;
+ esac
+done
+echo 'PASS: Zig target feature reproduces and removes the same ARM64 helper import'
+cat >"$TEST_ROOT/defined.c" <<'C'
+unsigned long long __aarch64_ldadd8_acq_rel(unsigned long long add, unsigned long long *value) {
+ return __atomic_fetch_add(value, add, __ATOMIC_ACQ_REL);
+}
+C
+"$fixture/.tools/cross/aarch64-linux-gnu/cc" -O2 -fPIC \
+ -c "$TEST_ROOT/defined.c" -o "$TEST_ROOT/defined.o"
+shared "$TEST_ROOT/defined.o" "$TEST_ROOT/defined.so"
+sh "$CHECK" "$TEST_ROOT/defined.so"
+echo 'PASS: a defined helper is not mistaken for an unresolved import'
+cp "$TEST_ROOT/inline.so" "$TEST_ROOT/-inline.so"
+(
+ cd "$TEST_ROOT"
+ sh "$CHECK" -inline.so
+)
+reject '__aarch64_ldadd8_acq_rel' sh "$CHECK" "$TEST_ROOT/inline.so" "$TEST_ROOT/outlined.so"
+reject 'not a linked executable' sh "$CHECK" "$TEST_ROOT/outlined.o"
+"$ZIG" cc -target x86_64-linux-gnu.2.38 -O2 -fPIC \
+ -c "$TEST_ROOT/atomic.c" -o "$TEST_ROOT/foreign.o"
+shared "$TEST_ROOT/foreign.o" "$TEST_ROOT/foreign.so" x86_64-linux-gnu.2.38
+reject 'not an AArch64 ELF' sh "$CHECK" "$TEST_ROOT/foreign.so"
+reject 'usage:' sh "$CHECK"
+reject 'missing.so' sh "$CHECK" "$TEST_ROOT/missing.so"
+printf 'not an ELF\n' >"$TEST_ROOT/not-elf"
+reject 'not-elf' sh "$CHECK" "$TEST_ROOT/not-elf"
+if READELF=false sh "$CHECK" "$TEST_ROOT/inline.so" >/dev/null 2>&1; then
+ echo 'reader failure was accepted' >&2
+ exit 1
+fi
+echo 'PASS: sidecar imports, wrong architecture/type, invalid files and reader failures are rejected'
+# Exercise the standalone package entry point, without invoking PortMaster.
+cp "$ROOT/tools/package-portmaster.sh" "$CHECK" "$fixture/tools/"
+mkdir -p "$fixture/zig-out/bin" "$fixture/zig-out/lib" "$fixture/zig-out/rockchip"
+for path in bin/webrtc_stream lib/libgreenovercast-cedar.so \
+ rockchip/libgreenovercast-mpp.so rockchip/librockchip_mpp.so.1 \
+ rockchip/greenovercast-mpp-probe.aarch64 bin/libavcodec.so.63 \
+ bin/libavutil.so.61 bin/libswscale.so.10 bin/libavformat.so.63 bin/libswresample.so.7; do
+ cp "$TEST_ROOT/inline.so" "$fixture/zig-out/$path"
+done
+chmod +x "$fixture/zig-out/bin/webrtc_stream"
+PORTMASTER_NEW='' reject 'set PORTMASTER_NEW' sh "$fixture/tools/package-portmaster.sh"
+cp "$TEST_ROOT/outlined.so" "$fixture/zig-out/bin/webrtc_stream"
+PORTMASTER_NEW='' reject '__aarch64_ldadd8_acq_rel' sh "$fixture/tools/package-portmaster.sh"
+cp "$TEST_ROOT/inline.so" "$fixture/zig-out/bin/webrtc_stream"
+cp "$TEST_ROOT/outlined.so" "$fixture/zig-out/bin/libavcodec.so.63"
+PORTMASTER_NEW='' reject '__aarch64_ldadd8_acq_rel' sh "$fixture/tools/package-portmaster.sh"
+echo 'PASS: standalone packaging rejects both a broken executable and a broken bundled library'
diff --git a/tests/cedar_list_test.c b/tests/cedar_list_test.c
new file mode 100644
index 0000000..834a5e8
--- /dev/null
+++ b/tests/cedar_list_test.c
@@ -0,0 +1,37 @@
+#include
+
+struct Entry {
+ CdxListNodeT node;
+ int value;
+};
+
+struct PaddedEntry {
+ int value;
+ CdxListNodeT node;
+};
+
+int main(void) {
+ CdxListT list;
+ CdxListInit(&list);
+ struct Entry* current;
+ int total = 0;
+ CdxListForEachEntry(current, &list, node) {
+ total += current->value;
+ }
+ if (total != 0 || !CdxListEmpty(&list))
+ return 1;
+
+ struct Entry entry = {.value = 7};
+ CdxListAdd(&entry.node, &list);
+ CdxListForEachEntry(current, &list, node) {
+ total += current->value;
+ }
+ if (total != 7 || CdxListEntry(&entry.node, struct Entry, node) != &entry)
+ return 1;
+ CdxListDel(&entry.node);
+ if (!CdxListEmpty(&list))
+ return 1;
+
+ struct PaddedEntry padded = {.value = 3};
+ return CdxListEntry(&padded.node, struct PaddedEntry, node) != &padded;
+}
diff --git a/tests/dependency_toolchain_test.sh b/tests/dependency_toolchain_test.sh
new file mode 100644
index 0000000..675aac1
--- /dev/null
+++ b/tests/dependency_toolchain_test.sh
@@ -0,0 +1,130 @@
+#!/bin/sh
+set -eu
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/greenovercast-toolchain-test.XXXXXX")
+trap 'rm -rf "$TEST_ROOT"' EXIT HUP INT TERM
+
+fixture="$TEST_ROOT/project"
+prefix="$fixture/.tools/deps/aarch64-linux-gnu"
+host_prefix="$TEST_ROOT/host"
+mkdir -p "$fixture/tools" "$fixture/.tools/zig-0.14.1" \
+ "$prefix/lib/pkgconfig" "$host_prefix/lib/pkgconfig"
+cp "$ROOT/tools/build-dependencies.sh" "$fixture/tools/"
+printf '#!/bin/sh\nexit 1\n' >"$fixture/.tools/zig-0.14.1/zig"
+chmod +x "$fixture/.tools/zig-0.14.1/zig"
+
+cat >"$prefix/lib/pkgconfig/target-only.pc" <"$host_prefix/lib/pkgconfig/host-only.pc" <"$fixture/CMakeLists.txt" <<'EOF'
+cmake_minimum_required(VERSION 3.24)
+project(TargetPkgConfig NONE)
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(TARGET_ONLY REQUIRED target-only)
+pkg_check_modules(HOST_ONLY QUIET host-only)
+if(HOST_ONLY_FOUND)
+ message(FATAL_ERROR "Cross toolchain accepted a host pkg-config package")
+endif()
+if(NOT TARGET_ONLY_LIBRARY_DIRS STREQUAL "${TARGET_PREFIX}/lib")
+ message(FATAL_ERROR "Cross toolchain changed the target library prefix")
+endif()
+if(NOT TARGET_ONLY_INCLUDE_DIRS STREQUAL "${TARGET_PREFIX}/include")
+ message(FATAL_ERROR "Cross toolchain changed the target include prefix")
+endif()
+EOF
+
+if ! cmake -S "$fixture" -B "$fixture/build" \
+ -DCMAKE_TOOLCHAIN_FILE="$fixture/.tools/cross/aarch64-linux-gnu/toolchain.cmake" \
+ -DCMAKE_PREFIX_PATH="$host_prefix" -DTARGET_PREFIX="$prefix" \
+ >"$TEST_ROOT/configure.log" 2>&1; then
+ cat "$TEST_ROOT/configure.log" >&2
+ exit 1
+fi
+echo "Dependency toolchain isolation passed"
+
+# Stop before downloads, but exercise the real cache invalidation path.
+mock_bin="$TEST_ROOT/mock-bin"
+mkdir -p "$mock_bin" "$fixture/vendor/libdatachannel" "$fixture/vendor/mpp"
+: >"$fixture/vendor/libdatachannel/CMakeLists.txt"
+: >"$fixture/vendor/mpp/CMakeLists.txt"
+cat >"$mock_bin/git" <<'EOF_GIT'
+#!/bin/sh
+case "$2" in
+ */libdatachannel) echo c6696d157b5612df2a741d9a03b192b47ab6cefb ;;
+ */mpp) echo c08762ebfadeb4e986d2fed993bc7a54862d3ebe ;;
+ *) exit 1 ;;
+esac
+EOF_GIT
+cat >"$mock_bin/curl" <<'EOF_CURL'
+#!/bin/sh
+echo 'fixture: downloads intentionally disabled' >&2
+exit 1
+EOF_CURL
+chmod +x "$mock_bin/git" "$mock_bin/curl"
+run_until_download() {
+ if PATH="$mock_bin:$PATH" sh "$fixture/tools/build-dependencies.sh" \
+ >"$TEST_ROOT/cache.log" 2>&1; then
+ echo 'dependency fixture unexpectedly succeeded' >&2
+ exit 1
+ fi
+ if ! grep -F 'fixture: downloads intentionally disabled' "$TEST_ROOT/cache.log" >/dev/null; then
+ cat "$TEST_ROOT/cache.log" >&2
+ exit 1
+ fi
+}
+for directory in "$prefix" \
+ "$fixture/.tools/build/dependencies-aarch64" \
+ "$fixture/.tools/build/libdatachannel-aarch64-release" \
+ "$fixture/.tools/build/mpp-aarch64-release"; do
+ mkdir -p "$directory"
+ : >"$directory/stale-output"
+done
+run_until_download
+for directory in "$prefix" \
+ "$fixture/.tools/build/dependencies-aarch64" \
+ "$fixture/.tools/build/libdatachannel-aarch64-release" \
+ "$fixture/.tools/build/mpp-aarch64-release"; do
+ [ ! -e "$directory/stale-output" ] || {
+ echo "stale dependency survived: $directory" >&2
+ exit 1
+ }
+done
+: >"$prefix/fresh-output"
+run_until_download
+[ -f "$prefix/fresh-output" ] || {
+ echo 'unchanged toolchain unnecessarily discarded the cache' >&2
+ exit 1
+}
+printf 'old toolchain\n' >"$fixture/.tools/build/dependencies-aarch64/.toolchain-signature"
+sh "$fixture/tools/build-dependencies.sh" --toolchain-only
+[ -f "$prefix/fresh-output" ] || {
+ echo '--toolchain-only discarded dependency outputs' >&2
+ exit 1
+}
+run_until_download
+[ ! -e "$prefix/fresh-output" ] || {
+ echo 'changed toolchain reused stale installed libraries' >&2
+ exit 1
+}
+echo 'Dependency cache invalidation and unchanged-toolchain reuse passed'
diff --git a/tests/device/README.md b/tests/device/README.md
new file mode 100644
index 0000000..d45bf58
--- /dev/null
+++ b/tests/device/README.md
@@ -0,0 +1,40 @@
+# Ad playback tests
+
+Build with `tools/zig.sh build ad-player-test`. The default test uses the native
+player with mock ad servers and tracking endpoints. It makes no network requests.
+
+Generate fixtures on a host with FFmpeg:
+
+```sh
+ffmpeg -f lavfi -i testsrc2=size=640x360:rate=30 \
+ -f lavfi -i sine=frequency=440:sample_rate=48000 -t 5 \
+ -c:v libx264 -profile:v high -bf 2 -g 60 -pix_fmt yuv420p \
+ -c:a aac -ac 2 -movflags +faststart ad-test.mp4
+ffmpeg -f lavfi -i color=blue:size=30x30 -frames:v 1 ad-icon.png
+ffmpeg -f lavfi -i testsrc2=size=1920x1080 -frames:v 1 ad-panel.png
+ffmpeg -i ad-test.mp4 -t 3 -c copy ad-short.mp4
+ffmpeg -i ad-test.mp4 -filter_complex '[0:a]atrim=duration=4[a]' \
+ -map 0:v -map '[a]' -c:v copy -c:a aac ad-video-tail.mp4
+```
+
+Copy the test executable, fixtures and release libraries into one directory on
+the target. Launch it through the device frontend, with no other app running:
+
+```sh
+LD_LIBRARY_PATH=. ./greenovercast-ad-test.aarch64 \
+ ad-test.mp4 ad-icon.png ad-short.mp4 ad-video-tail.mp4 ad-panel.png
+```
+
+It checks paced MP4/AAC playback, multi-ad pods, wrapper and inline tracking, AdChoices
+pause/resume, empty inventory, invalid and truncated media, video extending
+beyond audio, wrapper loops, and cancellation. A successful
+synthetic test does not prove that an ad provider served a real advertisement.
+
+For a live VAST URL, including a [Google sample ad](https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/tags):
+
+```sh
+LD_LIBRARY_PATH=. ./greenovercast-ad-test.aarch64 --live-ad "$VAST_URL"
+```
+
+This sends real media and tracking requests. NVIDIA session reports are recorded
+locally; no NVIDIA account is attached to the test.
diff --git a/tests/device/ad_playback_test.zig b/tests/device/ad_playback_test.zig
new file mode 100644
index 0000000..a03967b
--- /dev/null
+++ b/tests/device/ad_playback_test.zig
@@ -0,0 +1,323 @@
+const std = @import("std");
+const ads = @import("gfn_ads");
+const c = @cImport({
+ @cInclude("SDL2/SDL.h");
+});
+const allocator = std.heap.c_allocator;
+
+const inline_manifest =
+ "https://example.invalid/impression" ++
+ "https://example.invalid/error/[ERRORCODE]?at=[ADPLAYHEAD]" ++
+ "00:00:05" ++
+ "https://example.invalid/media" ++
+ "https://example.invalid/start" ++
+ "https://example.invalid/quarter" ++
+ "https://example.invalid/half" ++
+ "https://example.invalid/three-quarter" ++
+ "https://example.invalid/complete" ++
+ "https://example.invalid/pause" ++
+ "https://example.invalid/resume" ++
+ "" ++
+ "https://example.invalid/icon" ++
+ "https://example.invalid/icon-view" ++
+ "https://example.invalid/icon-click" ++
+ "" ++
+ "https://example.invalid/icon" ++
+ "";
+const wrapper_manifest =
+ "https://example.invalid/wrapper-view" ++
+ "https://example.invalid/wrapper-error/[ERRORCODE]" ++
+ "https://example.invalid/inline";
+const media_url = "https://example.invalid/media";
+const media_offset = std.mem.indexOf(u8, inline_manifest, media_url).?;
+const pod_manifest = "" ++
+ inline_manifest[10..media_offset] ++ media_url ++ "-2" ++ inline_manifest[media_offset + media_url.len .. inline_manifest.len - 12] ++
+ "" ++
+ inline_manifest[10..media_offset] ++ media_url ++ "-1" ++ inline_manifest[media_offset + media_url.len .. inline_manifest.len - 12] ++ "";
+
+const Mode = enum { error_report_failure, drain_cancel, wrapper_invalid_error_tracker, wrapper_loop_error_tracker, play, pause, large_fallback, slow_pause, audio_running, macros, tracker_failure, cancel, broken, error_tracker_failure, cancel_report_failure, empty, empty_report_failure, truncated, video_tail, pod, pod_truncated, pod_download_failure, wrapper_empty, wrapper_loop, wrapper_invalid };
+const Recorded = struct { update: ads.protocol.Update, time_ms: u64 };
+
+fn prependOversizedError(storage: std.mem.Allocator, manifest: []const u8) ![]u8 {
+ var oversized: [8192]u8 = @splat('x');
+ const prefix = "https://example.invalid/";
+ @memcpy(oversized[0..prefix.len], prefix);
+ const macro = "[ADPLAYHEAD]";
+ @memcpy(oversized[oversized.len - macro.len ..], macro);
+ const error_at = std.mem.indexOf(u8, manifest, "").?;
+ return std.fmt.allocPrint(storage, "{s}{s}{s}", .{ manifest[0..error_at], oversized, manifest[error_at..] });
+}
+
+const Context = struct {
+ media: []const u8,
+ image: []const u8,
+ fallback_image: []const u8 = "",
+ short_media: []const u8,
+ mode: Mode,
+ start: u64,
+ timeout_ms: u64 = 20000,
+ paused: bool = false,
+ resumed: bool = false,
+ playback_started_at: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
+ pause_accepted_at: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
+ media_reads: usize = 0,
+ media_order: [2]u8 = .{ 0, 0 },
+ error_beacon_started: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ report_rejections: usize = 0,
+ mutex: std.Thread.Mutex = .{},
+ updates: std.ArrayList(Recorded),
+ beacons: std.ArrayList([]u8),
+
+ fn read(pointer: *anyopaque, storage: std.mem.Allocator, address: []const u8, _: usize, abort: *std.atomic.Value(bool)) ![]u8 {
+ const self: *Context = @ptrCast(@alignCast(pointer));
+ if (abort.load(.acquire)) return error.Cancelled;
+ if (std.mem.indexOf(u8, address, "/tag?") != null) {
+ if (self.mode == .wrapper_invalid_error_tracker or self.mode == .wrapper_loop_error_tracker) return prependOversizedError(storage, wrapper_manifest);
+ return storage.dupe(u8, if (self.mode == .empty or self.mode == .empty_report_failure) "" else if (self.mode == .pod or self.mode == .pod_truncated or self.mode == .pod_download_failure) pod_manifest else wrapper_manifest);
+ }
+ if (std.mem.endsWith(u8, address, "/inline")) {
+ if (self.mode == .macros) return std.mem.replaceOwned(u8, storage, inline_manifest, "/impression", "/impression?asset=[ASSETURI]&custom=[VENDOR_TOKEN]");
+ if (self.mode == .large_fallback) return std.mem.replaceOwned(u8, storage, inline_manifest, "https://example.invalid/icon", "https://example.invalid/panel");
+ if (self.mode == .error_tracker_failure) return prependOversizedError(storage, inline_manifest);
+ return storage.dupe(u8, if (self.mode == .wrapper_empty) "" else if (self.mode == .wrapper_loop or self.mode == .wrapper_loop_error_tracker) wrapper_manifest else if (self.mode == .wrapper_invalid or self.mode == .wrapper_invalid_error_tracker) "" else inline_manifest);
+ }
+ if (std.mem.endsWith(u8, address, "/media") or std.mem.endsWith(u8, address, "/media-1") or std.mem.endsWith(u8, address, "/media-2")) {
+ if (self.media_reads < self.media_order.len) self.media_order[self.media_reads] = if (std.mem.endsWith(u8, address, "-1")) 1 else if (std.mem.endsWith(u8, address, "-2")) 2 else 0;
+ self.media_reads += 1;
+ if (self.mode == .slow_pause) c.SDL_Delay(1600);
+ if (self.mode == .pod_download_failure and self.media_reads == 2) return error.AdDownloadFailed;
+ return storage.dupe(u8, if (self.mode == .broken or self.mode == .error_tracker_failure or self.mode == .error_report_failure or self.mode == .cancel_report_failure or self.mode == .drain_cancel) "not an MP4" else if (self.mode == .pod_truncated and self.media_reads == 2) self.short_media else self.media);
+ }
+ if (std.mem.endsWith(u8, address, "/icon")) return storage.dupe(u8, self.image);
+ if (std.mem.endsWith(u8, address, "/panel")) return storage.dupe(u8, self.fallback_image);
+ if (self.mode == .error_report_failure or self.mode == .drain_cancel) self.error_beacon_started.store(true, .release);
+ if (self.mode == .drain_cancel) c.SDL_Delay(100);
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ const owned = try storage.dupe(u8, address);
+ self.beacons.append(owned) catch |err| {
+ storage.free(owned);
+ return err;
+ };
+ if (self.mode == .tracker_failure) return error.AdDownloadFailed;
+ return storage.alloc(u8, 0);
+ }
+
+ fn report(pointer: *anyopaque, update: ads.protocol.Update) !void {
+ const self: *Context = @ptrCast(@alignCast(pointer));
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ try self.updates.append(.{ .update = update, .time_ms = c.SDL_GetTicks64() - self.start });
+ if (update.action == .start) self.playback_started_at.store(c.SDL_GetTicks64(), .release);
+ if (update.action == .pause) self.pause_accepted_at.store(c.SDL_GetTicks64(), .release);
+ if (self.mode == .error_report_failure and update.action == .event) {
+ const until = c.SDL_GetTicks64() + 2000;
+ while (!self.error_beacon_started.load(.acquire) and c.SDL_GetTicks64() < until) c.SDL_Delay(1);
+ self.report_rejections += 1;
+ return error.AdReportRejected;
+ }
+ if ((self.mode == .cancel_report_failure or self.mode == .empty_report_failure) and update.action == .cancel) return error.AdReportRejected;
+ }
+
+ fn count(self: *Context, suffix: []const u8) usize {
+ var result: usize = 0;
+ for (self.beacons.items) |address| if (std.mem.endsWith(u8, address, suffix)) {
+ result += 1;
+ };
+ return result;
+ }
+};
+
+export fn go_handheld_ui_cancel_requested(pointer: ?*anyopaque) c_int {
+ const context: *Context = @ptrCast(@alignCast(pointer.?));
+ return @intFromBool(context.mode == .drain_cancel and context.error_beacon_started.load(.acquire));
+}
+export fn go_handheld_ui_draw_loading(_: ?*anyopaque, _: [*:0]const u8, _: [*:0]const u8, _: c_int) void {}
+export fn go_handheld_ui_draw_ad_hint(_: ?*anyopaque, _: c_int) void {}
+export fn go_handheld_ui_ad_action(pointer: ?*anyopaque) c_int {
+ const context: *Context = @ptrCast(@alignCast(pointer.?));
+ const elapsed = c.SDL_GetTicks64() - context.start;
+ if (elapsed > context.timeout_ms or (context.mode == .cancel and elapsed > 1000)) return -1;
+ if (context.mode == .pause or context.mode == .large_fallback or context.mode == .slow_pause) {
+ const started = context.playback_started_at.load(.acquire);
+ const accepted = context.pause_accepted_at.load(.acquire);
+ if (started != 0 and c.SDL_GetTicks64() - started > 500 and !context.paused) {
+ context.paused = true;
+ return 1;
+ }
+ if (accepted != 0 and c.SDL_GetTicks64() - accepted > 700 and !context.resumed) {
+ context.resumed = true;
+ return 2;
+ }
+ }
+ return 0;
+}
+
+fn require(value: bool) !void {
+ if (!value) return error.AssertionFailed;
+}
+
+fn readFixture(path: []const u8, limit: usize) ![]u8 {
+ const file = try std.fs.cwd().openFile(path, .{});
+ defer file.close();
+ return file.reader().readAllAlloc(allocator, limit);
+}
+
+fn run(renderer: *c.SDL_Renderer, device: c.SDL_AudioDeviceID, media: []const u8, short: []const u8, image: []const u8, fallback_image: []const u8, mode: Mode) !void {
+ c.SDL_PauseAudioDevice(device, @intFromBool(mode != .audio_running));
+ const previous_audio_status = c.SDL_GetAudioDeviceStatus(device);
+ var context = Context{ .media = media, .short_media = short, .image = image, .fallback_image = fallback_image, .mode = mode, .start = c.SDL_GetTicks64(), .updates = std.ArrayList(Recorded).init(allocator), .beacons = std.ArrayList([]u8).init(allocator) };
+ defer {
+ for (context.beacons.items) |address| allocator.free(address);
+ context.beacons.deinit();
+ context.updates.deinit();
+ }
+ var worker = ads.network.Worker{ .allocator = allocator, .reporter = .{ .context = &context, .send = Context.report }, .source = .{ .context = &context, .read = Context.read } };
+ try worker.start();
+ defer worker.deinit();
+ var playback = ads.Playback{ .renderer = @ptrCast(renderer), .device = device, .ui = @ptrCast(&context), .provider = 1, .app_id = "1", .opportunity = .{ .id = "test-pod", .url = "https://example.invalid/tag", .state = 1, .duration_ms = 5000 } };
+ const failure: ?anyerror = result: {
+ playback.execute(&worker) catch |err| break :result err;
+ break :result null;
+ };
+ worker.deinit();
+ try require(c.SDL_GetAudioDeviceStatus(device) == previous_audio_status);
+ const success = mode == .play or mode == .pause or mode == .large_fallback or mode == .slow_pause or mode == .audio_running or mode == .macros or mode == .tracker_failure or mode == .video_tail or mode == .pod;
+ const no_fill = mode == .empty or mode == .wrapper_empty;
+ try require(if (success or no_fill) failure == null else failure != null);
+ if (mode == .truncated) try require(failure.? == error.AdMediaTruncated);
+ if (mode == .broken or mode == .error_tracker_failure or mode == .error_report_failure or mode == .cancel_report_failure) try require(failure.? == error.InvalidMp4);
+ if (mode == .error_report_failure) try require(context.report_rejections == 1);
+ if (mode == .drain_cancel) try require(failure.? == error.Cancelled);
+ if (mode == .empty_report_failure) try require(failure.? == error.AdReportRejected);
+ if (mode == .error_tracker_failure) try require(context.count("/error/405?at=00%3A00%3A00.000") == 1);
+ if (mode == .pod or mode == .pod_truncated or mode == .pod_download_failure) try require(std.mem.eql(u8, &context.media_order, &.{ 1, 2 }));
+ if (mode == .pod_truncated) {
+ var first_end: ?u64 = null;
+ for (context.updates.items) |record| {
+ if (record.update.action == .event and std.mem.eql(u8, record.update.event.?, "COMPLETE")) {
+ first_end = record.update.watched_ms;
+ break;
+ }
+ }
+ try require(failure.? == error.AdMediaTruncated and first_end != null);
+ const local = playback.watched_ms - first_end.?;
+ try require(local >= 3098 and local <= 3100);
+ var buffer: [64]u8 = undefined;
+ const suffix = try std.fmt.bufPrint(&buffer, "/error/405?at=00%3A00%3A03.{d:0>3}", .{local % 1000});
+ try require(context.count(suffix) == 1);
+ }
+ if (mode == .pod_download_failure) {
+ try require(failure.? == error.AdDownloadFailed and playback.watched_ms >= 4998 and playback.watched_ms <= 5000);
+ try require(context.count("/error/401?at=00%3A00%3A00.000") == 1);
+ }
+ if (mode == .wrapper_loop or mode == .wrapper_loop_error_tracker) try require(failure.? == error.VastWrapperLimit);
+ if (mode == .wrapper_invalid or mode == .wrapper_invalid_error_tracker) {
+ try require(failure.? == error.InvalidXml);
+ try require(context.count("/wrapper-error/100") == 1);
+ }
+ var starts: usize = 0;
+ var finishes: usize = 0;
+ var cancels: usize = 0;
+ var milestones: usize = 0;
+ for (context.updates.items) |record| switch (record.update.action) {
+ .start => {
+ starts += 1;
+ try require(record.update.watched_ms == 0);
+ },
+ .finish => {
+ finishes += 1;
+ const duration: u64 = if (mode == .pod) 10000 else 5000;
+ try require(record.time_ms >= duration - 100 and record.update.watched_ms >= duration - 100 and record.update.watched_ms <= duration + 100);
+ if (mode == .pause or mode == .large_fallback or mode == .slow_pause) try require(record.update.paused_ms >= 700 and record.time_ms >= 5700);
+ },
+ .cancel => {
+ cancels += 1;
+ if (mode == .drain_cancel) try require(std.mem.eql(u8, record.update.reason.?, "USER_CANCELLED"));
+ if (mode == .empty) try require(record.update.watched_ms == 0 and
+ std.mem.eql(u8, record.update.reason.?, "VAST_EMPTY_RESPONSE"));
+ if (mode == .wrapper_empty) try require(record.update.watched_ms == 0 and
+ std.mem.eql(u8, record.update.reason.?, "VAST_NO_ADS_AFTER_WRAPPER"));
+ },
+ .event => {
+ if (std.mem.eql(u8, record.update.event.?, "FIRST_QUARTILE") or
+ std.mem.eql(u8, record.update.event.?, "MIDPOINT") or
+ std.mem.eql(u8, record.update.event.?, "THIRD_QUARTILE")) milestones += 1;
+ },
+ else => {},
+ };
+ try require(finishes == @intFromBool(success) and cancels == @intFromBool(!success));
+ if (success) {
+ const ads_played: usize = if (mode == .pod) 2 else 1;
+ try require(starts == 1 and milestones == 3 * ads_played);
+ for ([_][]const u8{ "/impression", "/start", "/quarter", "/half", "/three-quarter", "/complete", "/icon-view" }) |suffix| {
+ const expected = if (mode == .macros and std.mem.eql(u8, suffix, "/impression")) "/impression?asset=-1&custom=[VENDOR_TOKEN]" else suffix;
+ try require(context.count(expected) == ads_played);
+ }
+ try require(context.count("/wrapper-view") == @intFromBool(mode != .pod));
+ if (mode == .pause or mode == .large_fallback or mode == .slow_pause) for ([_][]const u8{ "/pause", "/resume", "/icon-click" }) |suffix| try require(context.count(suffix) == 1);
+ } else try require(context.count("/complete") == @intFromBool(mode == .pod_truncated or mode == .pod_download_failure));
+ std.debug.print("PASS ad playback: {s}, watched={d}ms paused={d}ms reports={d} beacons={d}\n", .{ @tagName(mode), playback.watched_ms, playback.paused_ms, context.updates.items.len, context.beacons.items.len });
+}
+
+pub fn main() !void {
+ const args = try std.process.argsAlloc(allocator);
+ defer std.process.argsFree(allocator, args);
+ const live = args.len == 3 and std.mem.eql(u8, args[1], "--live-ad");
+ if (!live and args.len != 6) return error.ExpectedMp4PngShortVideoTailAndFallbackFixtures;
+ if (c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_AUDIO) != 0) return error.SdlUnavailable;
+ defer c.SDL_Quit();
+ const window = c.SDL_CreateWindow("Ad playback test", 0, 0, 640, 480, c.SDL_WINDOW_FULLSCREEN) orelse return error.WindowUnavailable;
+ defer c.SDL_DestroyWindow(window);
+ const renderer = c.SDL_CreateRenderer(window, -1, c.SDL_RENDERER_ACCELERATED) orelse
+ c.SDL_CreateRenderer(window, -1, 0) orelse return error.RendererUnavailable;
+ defer c.SDL_DestroyRenderer(renderer);
+ _ = c.SDL_RenderSetLogicalSize(renderer, 640, 480);
+ var spec = std.mem.zeroes(c.SDL_AudioSpec);
+ spec.freq = 48000;
+ spec.channels = 2;
+ spec.format = c.AUDIO_S16SYS;
+ spec.samples = 960;
+ const device = c.SDL_OpenAudioDevice(null, 0, &spec, null, 0);
+ if (device == 0) return error.AudioUnavailable;
+ defer c.SDL_CloseAudioDevice(device);
+ if (live) return runLive(renderer, device, args[2]);
+ const media = try readFixture(args[1], 96 * 1024 * 1024);
+ defer allocator.free(media);
+ const image = try readFixture(args[2], 4 * 1024 * 1024);
+ defer allocator.free(image);
+ const short = try readFixture(args[3], 96 * 1024 * 1024);
+ defer allocator.free(short);
+ const tail = try readFixture(args[4], 96 * 1024 * 1024);
+ defer allocator.free(tail);
+ const fallback_image = try readFixture(args[5], 4 * 1024 * 1024);
+ defer allocator.free(fallback_image);
+ for (std.enums.values(Mode)) |mode|
+ try run(renderer, device, if (mode == .truncated) short else if (mode == .video_tail) tail else media, short, image, fallback_image, mode);
+}
+
+fn runLive(renderer: *c.SDL_Renderer, device: c.SDL_AudioDeviceID, address: []const u8) !void {
+ var context = Context{ .media = "", .short_media = "", .image = "", .mode = .pause, .start = c.SDL_GetTicks64(), .timeout_ms = 180000, .updates = std.ArrayList(Recorded).init(allocator), .beacons = std.ArrayList([]u8).init(allocator) };
+ defer context.updates.deinit();
+ defer context.beacons.deinit();
+ var worker = ads.network.Worker{ .allocator = allocator, .reporter = .{ .context = &context, .send = Context.report } };
+ try worker.start();
+ defer worker.deinit();
+ var playback = ads.Playback{ .renderer = @ptrCast(renderer), .device = device, .ui = @ptrCast(&context), .provider = 1, .app_id = "1", .opportunity = .{ .id = "live-component-test", .url = address, .state = 1, .duration_ms = 0 } };
+ std.debug.print("Live ad component test: real network/media/tracking; no NVIDIA account attached\n", .{});
+ try playback.execute(&worker);
+ const until = c.SDL_GetTicks64() + 30000;
+ while (!try worker.idle() and c.SDL_GetTicks64() < until) c.SDL_Delay(16);
+ try require(try worker.idle());
+ try require(worker.beacon_failures == 0 and playback.watched_ms > 0);
+ var starts: usize = 0;
+ var finishes: usize = 0;
+ for (context.updates.items) |record| {
+ if (record.update.action == .start) starts += 1;
+ if (record.update.action == .finish) finishes += 1;
+ try require(record.update.action != .cancel);
+ }
+ try require(starts == 1 and finishes == 1);
+ if (context.pause_accepted_at.load(.acquire) != 0) try require(context.resumed and playback.paused_ms >= 700);
+ std.debug.print("PASS live ad: watched={d}ms paused={d}ms reports={d}, no failed tracking requests\n", .{ playback.watched_ms, playback.paused_ms, context.updates.items.len });
+}
diff --git a/tests/gfn_http_fake.zig b/tests/gfn_http_fake.zig
new file mode 100644
index 0000000..f3d4cd8
--- /dev/null
+++ b/tests/gfn_http_fake.zig
@@ -0,0 +1,107 @@
+const std = @import("std");
+
+const Response = extern struct {
+ data: ?[*]u8,
+ len: usize,
+ status: c_long,
+};
+
+pub var requests: usize = 0;
+pub var polls: usize = 0;
+pub var cancelled = false;
+pub var cancel_on_poll: usize = 1;
+pub var response: ?*Response = null;
+
+pub fn reset() void {
+ requests = 0;
+ polls = 0;
+ cancelled = false;
+ cancel_on_poll = 1;
+ response = null;
+}
+
+var no_content = Response{ .data = null, .len = 0, .status = 204 };
+var json_response: Response = undefined;
+
+pub fn replyJson(data: []const u8) void {
+ json_response = .{ .data = @constCast(data.ptr), .len = data.len, .status = 200 };
+ response = &json_response;
+}
+
+pub fn replyNoContent() void {
+ response = &no_content;
+}
+
+pub fn authClient(comptime Client: type) Client {
+ return .{
+ .allocator = std.testing.allocator,
+ .ui = @ptrFromInt(1),
+ .oauth_client_id = @constCast("test-client"),
+ .protocol_client_id = @constCast("test-protocol"),
+ .credential_path = @constCast("unused"),
+ .key_path = @constCast("unused"),
+ .device_id_path = @constCast("unused"),
+ .device_id = [_]u8{0} ** 37,
+ .tokens = .{
+ .allocator = std.testing.allocator,
+ .access_token = @constCast("header.eyJzdWIiOiJ0ZXN0LXVzZXIifQ.signature"),
+ .refresh_token = null,
+ .id_token = null,
+ .client_token = null,
+ .expires_at = 0,
+ },
+ };
+}
+
+export fn go_http_request_bounded_cancelable(
+ _: [*:0]const u8,
+ _: [*:0]const u8,
+ _: ?[*:0]const u8,
+ _: ?[*]const [*:0]const u8,
+ _: c_int,
+ _: usize,
+ cancel: ?*const fn (?*anyopaque) callconv(.c) c_int,
+ context: ?*anyopaque,
+) ?*Response {
+ requests += 1;
+ if (cancel) |check| {
+ if (check(context) != 0) return null;
+ }
+ return response;
+}
+
+export fn go_http_response_destroy(_: ?*Response) void {}
+
+export fn go_http_response_succeeded(value: ?*const Response) c_int {
+ const result = value orelse return 0;
+ return @intFromBool(result.status >= 200 and result.status < 300);
+}
+
+export fn go_http_request_bounded(
+ _: [*:0]const u8,
+ _: [*:0]const u8,
+ _: ?[*:0]const u8,
+ _: ?[*]const [*:0]const u8,
+ _: c_int,
+ _: usize,
+) ?*Response {
+ requests += 1;
+ return response;
+}
+
+export fn go_handheld_ui_cancel_requested(_: ?*anyopaque) c_int {
+ polls += 1;
+ if (cancelled or polls < cancel_on_poll) return 0;
+ cancelled = true;
+ return 1;
+}
+
+export fn go_handheld_ui_cancelled(_: ?*anyopaque) c_int {
+ return @intFromBool(cancelled);
+}
+
+export fn go_handheld_ui_wait(_: ?*anyopaque, _: u32) c_int {
+ return 0;
+}
+
+export fn go_handheld_ui_draw_loading(_: ?*anyopaque, _: [*:0]const u8, _: [*:0]const u8, _: c_int) void {}
diff --git a/tests/http_client_test.zig b/tests/http_client_test.zig
new file mode 100644
index 0000000..fd046b0
--- /dev/null
+++ b/tests/http_client_test.zig
@@ -0,0 +1,70 @@
+const std = @import("std");
+const c = @cImport({
+ @cInclude("http_client.h");
+ @cInclude("curl/curl.h");
+});
+
+const Receiver = struct {
+ server: *std.net.Server,
+ method: ?std.http.Method = null,
+ body: [256]u8 = undefined,
+ body_length: usize = 0,
+ failure: ?anyerror = null,
+
+ fn run(self: *Receiver) void {
+ self.receive() catch |err| {
+ self.failure = err;
+ };
+ }
+
+ fn receive(self: *Receiver) !void {
+ var descriptors = [_]std.posix.pollfd{.{
+ .fd = self.server.stream.handle,
+ .events = std.posix.POLL.IN,
+ .revents = 0,
+ }};
+ if (try std.posix.poll(&descriptors, 5000) == 0) return error.AcceptTimedOut;
+ const connection = try self.server.accept();
+ defer connection.stream.close();
+ const timeout = std.posix.timeval{ .sec = 5, .usec = 0 };
+ try std.posix.setsockopt(connection.stream.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&timeout));
+ var buffer: [4096]u8 = undefined;
+ var http = std.http.Server.init(connection, &buffer);
+ var request = try http.receiveHead();
+ self.method = request.head.method;
+ const reader = try request.reader();
+ self.body_length = try reader.readAll(&self.body);
+ try request.respond("ok", .{ .keep_alive = false });
+ }
+};
+
+fn checkRequest(method: [:0]const u8, body: ?[:0]const u8, expected: std.http.Method) !void {
+ const loopback = try std.net.Address.parseIp4("127.0.0.1", 0);
+ var server = try loopback.listen(.{});
+ defer server.deinit();
+ const address = try std.fmt.allocPrintZ(std.testing.allocator, "http://127.0.0.1:{d}/session", .{server.listen_address.getPort()});
+ defer std.testing.allocator.free(address);
+ var receiver = Receiver{ .server = &server };
+ const thread = try std.Thread.spawn(.{}, Receiver.run, .{&receiver});
+ const response = c.go_http_request_bounded(method.ptr, address.ptr, if (body) |value| value.ptr else null, null, 0, 256);
+ thread.join();
+ defer c.go_http_response_destroy(response);
+ if (receiver.failure) |err| return err;
+ try std.testing.expect(response != null);
+ try std.testing.expectEqual(@as(c_long, 200), response.*.status);
+ try std.testing.expectEqual(expected, receiver.method.?);
+ try std.testing.expectEqualStrings(body orelse "", receiver.body[0..receiver.body_length]);
+ try std.testing.expectEqualStrings("ok", response.*.data[0..response.*.len]);
+}
+
+test "HTTP request methods preserve their bodies on the wire" {
+ try std.testing.expect(c.curl_global_init(c.CURL_GLOBAL_DEFAULT) == c.CURLE_OK);
+ defer c.curl_global_cleanup();
+ try checkRequest("PUT", "{\"action\":6,\"adUpdates\":[]}", .PUT);
+ try checkRequest("PATCH", "{\"value\":true}", .PATCH);
+ try checkRequest("POST", "{\"appId\":1}", .POST);
+ try checkRequest("POST", null, .POST);
+ try checkRequest("GET", null, .GET);
+ try checkRequest("GET", "query body", .GET);
+ try checkRequest("DELETE", null, .DELETE);
+}
diff --git a/tools/build-dependencies.sh b/tools/build-dependencies.sh
index 92b4b98..c1790f4 100755
--- a/tools/build-dependencies.sh
+++ b/tools/build-dependencies.sh
@@ -19,6 +19,7 @@ CXX_WRAPPER="$CROSS/c++"
AR_WRAPPER="$CROSS/ar"
RANLIB_WRAPPER="$CROSS/ranlib"
TOOLCHAIN="$CROSS/toolchain.cmake"
+CLANG_CONFIG="$CROSS/clang.cfg"
LIBDATACHANNEL_SOURCE="$ROOT/vendor/libdatachannel"
LIBDATACHANNEL_BUILD="$TOOLS/build/libdatachannel-aarch64-release"
LIBDATACHANNEL_COMMIT=c6696d157b5612df2a741d9a03b192b47ab6cefb
@@ -32,7 +33,7 @@ MPP_COMMIT=c08762ebfadeb4e986d2fed993bc7a54862d3ebe
exit 1
}
-for command_name in cmake curl make patch perl python3 tar; do
+for command_name in cmake curl make patch perl pkg-config python3 tar; do
command -v "$command_name" >/dev/null 2>&1 || {
echo "missing build tool: $command_name" >&2
exit 1
@@ -41,17 +42,24 @@ done
mkdir -p "$DOWNLOADS" "$SOURCES" "$BUILDS" "$PREFIX" "$CROSS" "$TOOLS/cache/zig"
+# pkg-config's host search paths are independent of CMake's find root.
+export PKG_CONFIG_LIBDIR="$PREFIX/lib/pkgconfig:$PREFIX/share/pkgconfig"
+export PKG_CONFIG_PATH=
+export PKG_CONFIG_SYSROOT_DIR=
+
write_wrapper() {
wrapper=$1
driver=$2
{
printf '%s\n' '#!/bin/sh'
- printf 'exec "%s" %s -target aarch64-linux-gnu.2.38 -ffile-prefix-map="%s"=. "$@"\n' \
- "$ZIG" "$driver" "$ROOT"
+ printf 'exec "%s" %s -target aarch64-linux-gnu.2.38 -ffile-prefix-map="%s"=. "$@" -mno-outline-atomics --config="%s"\n' \
+ "$ZIG" "$driver" "$ROOT" "$CLANG_CONFIG"
} >"$wrapper"
chmod +x "$wrapper"
}
+# Zig 0.14.1 consumes -m flags before Clang applies its Linux defaults.
+printf '%s\n' '-mno-outline-atomics' >"$CLANG_CONFIG"
write_wrapper "$CC_WRAPPER" cc
write_wrapper "$CXX_WRAPPER" c++
{
@@ -77,6 +85,10 @@ chmod +x "$AR_WRAPPER" "$RANLIB_WRAPPER"
printf '%s\n' 'set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)'
printf '%s\n' 'set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)'
printf '%s\n' 'set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)'
+ printf 'set(ENV{PKG_CONFIG_LIBDIR} "%s")\n' "$PKG_CONFIG_LIBDIR"
+ printf '%s\n' 'set(ENV{PKG_CONFIG_PATH} "")'
+ printf '%s\n' 'set(ENV{PKG_CONFIG_SYSROOT_DIR} "")'
+ printf '%s\n' 'set(PKG_CONFIG_USE_CMAKE_PREFIX_PATH FALSE)'
} >"$TOOLCHAIN"
if [ "${1:-}" = "--toolchain-only" ]; then
@@ -103,6 +115,19 @@ actual_mpp_commit=$(git -C "$MPP_SOURCE" rev-parse HEAD)
exit 1
}
+# Compiler flags are not part of the per-library completion stamps. Invalidate
+# both build trees and installed libraries when the generated toolchain changes.
+toolchain_signature=$(cksum "$CC_WRAPPER" "$CXX_WRAPPER" "$TOOLCHAIN" "$CLANG_CONFIG")
+cached_signature=
+if [ -f "$BUILDS/.toolchain-signature" ]; then
+ cached_signature=$(cat "$BUILDS/.toolchain-signature")
+fi
+if [ "$cached_signature" != "$toolchain_signature" ]; then
+ rm -rf "$BUILDS" "$LIBDATACHANNEL_BUILD" "$MPP_BUILD" "$PREFIX"
+ mkdir -p "$BUILDS" "$PREFIX"
+ printf '%s\n' "$toolchain_signature" >"$BUILDS/.toolchain-signature"
+fi
+
hash_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
@@ -178,6 +203,12 @@ fetch_source libdrm-2.4.128.tar.xz libdrm-2.4.128 \
fetch_source SDL2-2.28.5.tar.gz SDL2-2.28.5 \
https://github.com/libsdl-org/SDL/releases/download/release-2.28.5/SDL2-2.28.5.tar.gz \
332cb37d0be20cb9541739c61f79bae5a477427d79ae85e352089afdaf6666e4
+fetch_source zlib-1.3.2.tar.gz zlib-1.3.2 \
+ https://zlib.net/zlib-1.3.2.tar.gz \
+ bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16
+fetch_source expat-2.8.5.tar.xz expat-2.8.5 \
+ https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz \
+ 1e727b8933ec51a77a9a9d9afcf8e688bce45d907c13e36ab7393fe36e703182
export ZIG_GLOBAL_CACHE_DIR="$TOOLS/cache/zig"
export SOURCE_DATE_EPOCH=0
@@ -221,8 +252,8 @@ if [ ! -f "$BUILDS/.opus-1.6.1-static" ]; then
: >"$BUILDS/.opus-1.6.1-static"
fi
-if [ ! -f "$BUILDS/.curl-8.20.0-static-http" ]; then
- curl_build="$BUILDS/curl-8.20.0-static-http"
+if [ ! -f "$BUILDS/.curl-8.20.0-static-http-ws" ]; then
+ curl_build="$BUILDS/curl-8.20.0-static-http-ws"
cmake -S "$SOURCES/curl-8.20.0" -B "$curl_build" \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
@@ -232,7 +263,22 @@ if [ ! -f "$BUILDS/.curl-8.20.0-static-http" ]; then
-DBUILD_SHARED_LIBS=OFF \
-DBUILD_STATIC_LIBS=ON \
-DBUILD_TESTING=OFF \
- -DHTTP_ONLY=ON \
+ -DHTTP_ONLY=OFF \
+ -DCURL_DISABLE_WEBSOCKETS=OFF \
+ -DCURL_DISABLE_DICT=ON \
+ -DCURL_DISABLE_FILE=ON \
+ -DCURL_DISABLE_FTP=ON \
+ -DCURL_DISABLE_GOPHER=ON \
+ -DCURL_DISABLE_IMAP=ON \
+ -DCURL_DISABLE_IPFS=ON \
+ -DCURL_DISABLE_LDAP=ON \
+ -DCURL_DISABLE_LDAPS=ON \
+ -DCURL_DISABLE_MQTT=ON \
+ -DCURL_DISABLE_POP3=ON \
+ -DCURL_DISABLE_RTSP=ON \
+ -DCURL_DISABLE_SMTP=ON \
+ -DCURL_DISABLE_TELNET=ON \
+ -DCURL_DISABLE_TFTP=ON \
-DCURL_USE_OPENSSL=ON \
-DOPENSSL_ROOT_DIR="$PREFIX" \
-DOPENSSL_USE_STATIC_LIBS=ON \
@@ -252,12 +298,12 @@ if [ ! -f "$BUILDS/.curl-8.20.0-static-http" ]; then
-DCURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
cmake --build "$curl_build" --parallel
cmake --install "$curl_build"
- : >"$BUILDS/.curl-8.20.0-static-http"
+ : >"$BUILDS/.curl-8.20.0-static-http-ws"
fi
-if [ ! -f "$BUILDS/.sdl2-2.28.5-link" ]; then
+if [ ! -f "$BUILDS/.sdl2-2.28.5-link-v2" ]; then
sdl_build="$BUILDS/SDL2-2.28.5-link"
- cmake -S "$SOURCES/SDL2-2.28.5" -B "$sdl_build" \
+ cmake --fresh -S "$SOURCES/SDL2-2.28.5" -B "$sdl_build" \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DCMAKE_INSTALL_LIBDIR=lib \
@@ -283,7 +329,7 @@ if [ ! -f "$BUILDS/.sdl2-2.28.5-link" ]; then
-DSDL_FCITX=OFF
cmake --build "$sdl_build" --parallel
cmake --install "$sdl_build"
- : >"$BUILDS/.sdl2-2.28.5-link"
+ : >"$BUILDS/.sdl2-2.28.5-link-v2"
fi
if [ ! -f "$BUILDS/.libudev-zero-1.0.3-static" ]; then
@@ -305,22 +351,49 @@ if [ ! -f "$BUILDS/.libdrm-2.4.128-v4l2-headers" ]; then
: >"$BUILDS/.libdrm-2.4.128-v4l2-headers"
fi
-if [ ! -f "$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" ]; then
- ffmpeg_build="$BUILDS/ffmpeg-9.0-h264-mjpeg-v4l2-request-shared"
+if [ ! -f "$BUILDS/.zlib-1.3.2-static" ]; then
+ zlib_build="$BUILDS/zlib-1.3.2-static"
+ cmake -S "$SOURCES/zlib-1.3.2" -B "$zlib_build" \
+ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DCMAKE_INSTALL_PREFIX="$PREFIX" \
+ -DCMAKE_POSITION_INDEPENDENT_CODE=ON -DCMAKE_BUILD_TYPE=Release \
+ -DZLIB_BUILD_SHARED=OFF -DZLIB_BUILD_STATIC=ON -DZLIB_BUILD_TESTING=OFF
+ cmake --build "$zlib_build" --parallel
+ cmake --install "$zlib_build"
+ : >"$BUILDS/.zlib-1.3.2-static"
+fi
+
+if [ ! -f "$BUILDS/.expat-2.8.5-static" ]; then
+ expat_build="$BUILDS/expat-2.8.5-static"
+ cmake -S "$SOURCES/expat-2.8.5" -B "$expat_build" \
+ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DCMAKE_INSTALL_PREFIX="$PREFIX" \
+ -DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
+ -DCMAKE_BUILD_TYPE=Release -DEXPAT_SHARED_LIBS=OFF -DEXPAT_DTD=OFF \
+ -DEXPAT_BUILD_TOOLS=OFF -DEXPAT_BUILD_EXAMPLES=OFF -DEXPAT_BUILD_TESTS=OFF
+ cmake --build "$expat_build" --parallel
+ cmake --install "$expat_build"
+ : >"$BUILDS/.expat-2.8.5-static"
+fi
+
+if [ ! -f "$BUILDS/.ffmpeg-9.0-mp4-vast-v4l2-request-shared" ]; then
+ ffmpeg_build="$BUILDS/ffmpeg-9.0-mp4-vast-v4l2-request-shared"
ffmpeg_source="$BUILDS/ffmpeg-9.0"
ffmpeg_request_patch="$DOWNLOADS/ffmpeg-9.0-v4l2-request.patch"
ffmpeg_portable_patch="$ROOT/vendor/patches/ffmpeg-9.0-v4l2-request-portable.patch"
- rm -rf "$ffmpeg_build" "$ffmpeg_source"
- mkdir -p "$ffmpeg_build"
+ ffmpeg_patch_dir="$BUILDS/ffmpeg-9.0-patches"
+ rm -rf "$ffmpeg_build" "$ffmpeg_source" "$ffmpeg_patch_dir"
+ mkdir -p "$ffmpeg_build" "$ffmpeg_patch_dir"
tar -xf "$DOWNLOADS/ffmpeg-9.0.tar.xz" -C "$BUILDS"
- patch -d "$ffmpeg_source" -p1 <"$ffmpeg_request_patch"
+ # Later mailbox patches depend on edits made by earlier patches.
+ git mailsplit -o"$ffmpeg_patch_dir" "$ffmpeg_request_patch"
+ for request_patch in "$ffmpeg_patch_dir"/*; do
+ patch -d "$ffmpeg_source" -p1 <"$request_patch"
+ done
patch -d "$ffmpeg_source" -p1 <"$ffmpeg_portable_patch"
find "$ffmpeg_source" -name '*.orig' -delete
nm_tool=$(command -v llvm-nm || command -v nm)
(
cd "$ffmpeg_build"
env -u CPPFLAGS -u CFLAGS -u CXXFLAGS -u LDFLAGS \
- PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig" \
"$ffmpeg_source/configure" \
--prefix="$PREFIX" \
--arch=aarch64 \
@@ -338,10 +411,17 @@ if [ ! -f "$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" ]; then
--disable-all \
--enable-avcodec \
--enable-avutil \
+ --enable-avformat \
+ --enable-swresample \
--enable-swscale \
--enable-decoder=h264 \
--enable-decoder=mjpeg \
+ --enable-decoder=aac \
+ --enable-decoder=png \
--enable-parser=h264 \
+ --enable-parser=aac \
+ --enable-demuxer=mov \
+ --enable-zlib \
--enable-v4l2-request \
--enable-libudev \
--disable-libdrm \
@@ -353,12 +433,12 @@ if [ ! -f "$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" ]; then
--disable-doc \
--disable-debug \
--disable-stripping \
- --extra-cflags="-O3 -I$PREFIX/include/libdrm -ffile-prefix-map=$ROOT=." \
+ --extra-cflags="-O3 -I$PREFIX/include -I$PREFIX/include/libdrm -ffile-prefix-map=$ROOT=." \
--extra-ldflags="-L$PREFIX/lib"
make -j4
make install-libs install-headers
)
- : >"$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared"
+ : >"$BUILDS/.ffmpeg-9.0-mp4-vast-v4l2-request-shared"
fi
if [ ! -f "$LIBDATACHANNEL_BUILD/.greenovercast-$LIBDATACHANNEL_COMMIT" ]; then
diff --git a/tools/check-aarch64-runtime.sh b/tools/check-aarch64-runtime.sh
new file mode 100755
index 0000000..ae8e64a
--- /dev/null
+++ b/tools/check-aarch64-runtime.sh
@@ -0,0 +1,48 @@
+#!/bin/sh
+set -eu
+export LC_ALL=C
+if [ "$#" -eq 0 ]; then
+ echo "usage: $0 ELF_FILE..." >&2
+ exit 1
+fi
+reader=${READELF:-}
+if [ -z "$reader" ]; then
+ for candidate in llvm-readelf readelf greadelf; do
+ if command -v "$candidate" >/dev/null 2>&1; then
+ reader=$candidate
+ break
+ fi
+ done
+fi
+[ -n "$reader" ] || {
+ echo "missing llvm-readelf or GNU readelf; install LLVM or binutils" >&2
+ exit 1
+}
+status=0
+for artifact; do
+ case "$artifact" in
+ /*) ;;
+ *) artifact="./$artifact" ;;
+ esac
+ if ! symbols=$("$reader" -h -W --dyn-syms "$artifact" 2>&1); then
+ printf '%s\n' "$symbols" >&2
+ status=1
+ continue
+ fi
+ if ! printf '%s\n' "$symbols" | grep -Eq 'Machine:[[:space:]]+AArch64[[:space:]]*$'; then
+ printf '%s: not an AArch64 ELF artifact\n' "$artifact" >&2
+ status=1
+ continue
+ fi
+ if ! printf '%s\n' "$symbols" | grep -Eq 'Type:[[:space:]]+(DYN|EXEC)[[:space:]]'; then
+ printf '%s: not a linked executable or shared library\n' "$artifact" >&2
+ status=1
+ continue
+ fi
+ missing=$(printf '%s\n' "$symbols" | awk '$7 == "UND" && $8 ~ /^__aarch64_/ { print $8 }' | sort -u)
+ if [ -n "$missing" ]; then
+ printf '%s: unresolved AArch64 runtime helpers:\n%s\n' "$artifact" "$missing" >&2
+ status=1
+ fi
+done
+exit "$status"
diff --git a/tools/package-portmaster.sh b/tools/package-portmaster.sh
index 6adc739..a956f18 100755
--- a/tools/package-portmaster.sh
+++ b/tools/package-portmaster.sh
@@ -7,13 +7,15 @@ OUTPUT=${1:-$ROOT/zig-out/greenovercast.zip}
PORTMASTER_NEW=${PORTMASTER_NEW:-${2:-}}
SOURCE="$ROOT/packaging/portmaster/greenovercast"
BINARY="$ROOT/zig-out/bin/webrtc_stream"
-CEDAR="$ROOT/zig-out/bin/libgreenovercast-cedar.so"
+CEDAR="$ROOT/zig-out/lib/libgreenovercast-cedar.so"
MPP_PLUGIN="$ROOT/zig-out/rockchip/libgreenovercast-mpp.so"
MPP_RUNTIME="$ROOT/zig-out/rockchip/librockchip_mpp.so.1"
MPP_PROBE="$ROOT/zig-out/rockchip/greenovercast-mpp-probe.aarch64"
AVCODEC="$ROOT/zig-out/bin/libavcodec.so.63"
AVUTIL="$ROOT/zig-out/bin/libavutil.so.61"
SWSCALE="$ROOT/zig-out/bin/libswscale.so.10"
+AVFORMAT="$ROOT/zig-out/bin/libavformat.so.63"
+SWRESAMPLE="$ROOT/zig-out/bin/libswresample.so.7"
case "$OUTPUT" in
/*) ;;
@@ -34,12 +36,16 @@ for mpp_file in "$MPP_PLUGIN" "$MPP_RUNTIME" "$MPP_PROBE"; do
exit 1
}
done
-for private_library in "$AVCODEC" "$AVUTIL" "$SWSCALE"; do
+for private_library in "$AVCODEC" "$AVUTIL" "$SWSCALE" "$AVFORMAT" "$SWRESAMPLE"; do
[ -f "$private_library" ] || {
echo "missing private library: $private_library" >&2
exit 1
}
done
+sh "$ROOT/tools/check-aarch64-runtime.sh" \
+ "$BINARY" "$CEDAR" "$MPP_PLUGIN" "$MPP_RUNTIME" "$MPP_PROBE" \
+ "$AVCODEC" "$AVUTIL" "$SWSCALE" "$AVFORMAT" "$SWRESAMPLE"
+
[ -n "$PORTMASTER_NEW" ] || {
echo "set PORTMASTER_NEW to a current PortMaster-New checkout" >&2
exit 1
@@ -75,6 +81,8 @@ cp "$ROOT/vendor/mpp/LICENSES/MIT" \
cp "$AVCODEC" "$port/greenovercast/libavcodec.so.63"
cp "$AVUTIL" "$port/greenovercast/libavutil.so.61"
cp "$SWSCALE" "$port/greenovercast/libswscale.so.10"
+cp "$AVFORMAT" "$port/greenovercast/libavformat.so.63"
+cp "$SWRESAMPLE" "$port/greenovercast/libswresample.so.7"
find "$port" -type f -exec chmod 644 {} +
: >"$checker/.github_check"
@@ -118,6 +126,8 @@ required = {
"greenovercast/libavcodec.so.63",
"greenovercast/libavutil.so.61",
"greenovercast/libswscale.so.10",
+ "greenovercast/libavformat.so.63",
+ "greenovercast/libswresample.so.7",
}
expected_hashes = {
"greenovercast/rocknix/h700/greenovercast_h700_overlay.ko":
diff --git a/vendor/cedarx/base/include/CdxTypes.h b/vendor/cedarx/base/include/CdxTypes.h
index 99c5b59..e3302ae 100755
--- a/vendor/cedarx/base/include/CdxTypes.h
+++ b/vendor/cedarx/base/include/CdxTypes.h
@@ -18,6 +18,8 @@
#ifndef CDX_TYPES_H
#define CDX_TYPES_H
+#include
+
#ifdef NULL
#undef NULL
#endif
@@ -63,7 +65,7 @@ typedef cdx_int32 cdx_err;
#define CDX_TRUE 1
#define CDX_FALSE 0
-#define CdxOffsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
+#define CdxOffsetof(TYPE, MEMBER) offsetof(TYPE, MEMBER)
#define CdxContainerOf(ptr, type, member) ({ \
const typeof(((type *)0)->member) *__mptr = (ptr); \
diff --git a/vendor/manifest.lock b/vendor/manifest.lock
index f4d4d36..e2dd6cf 100644
--- a/vendor/manifest.lock
+++ b/vendor/manifest.lock
@@ -42,7 +42,15 @@ commit = "c08762ebfadeb4e986d2fed993bc7a54862d3ebe"
license = "Apache-2.0 and MIT"
linkage = "dynamic optional decoder plugin; firmware MPP preferred when compatible"
build = "git submodule headers and private userspace library; tools/build-dependencies.sh and build.zig"
-status = "live cloud playback proven on RK3566 SpruceOS 4.2.0 and RK3326 AmberELEC prerelease-20250515; RK3326 dArkOS 4.4 offline 720p decode and frontend startup also proven"
+status = "live cloud playback proven on RK3566 SpruceOS 4.2.0/4.3.3 and RK3326 AmberELEC prerelease-20250515; GFN gameplay tested on RK3326 dArkOS 02012026, with uneven render cadence"
+
+[asset.provider_icons]
+source = "https://github.com/twbs/icons"
+commit = "6945b7006285d444cc17ff2e22c7691719229526"
+license = "MIT"
+nvidia_svg_sha256 = "3e1027070f7db94347d631ebf6299cc01a3e813610af743506f5acc1257aef81"
+xbox_svg_sha256 = "e73c5df7997cfec0f6deed44c8a160bb649af865789cafd688db9bedaaf42e1a"
+build = "icons/nvidia.svg and icons/xbox.svg rasterized to 24x24 alpha masks in src/ui/provider_badge.zig"
[dep.libdatachannel]
version = "v0.24.3"
@@ -65,7 +73,7 @@ version = "8.20.0"
source = "https://curl.se/download/curl-8.20.0.tar.xz"
sha256 = "63fe2dc148ba0ceae89922ef838f7e5c946272c2e78b7c59fab4b79d3ce2b896"
license = "curl (MIT-style)"
-linkage = "static; HTTP and HTTPS only"
+linkage = "static; HTTP, HTTPS, and WebSocket"
status = "source-built by tools/build-dependencies.sh"
[dep.ffmpeg]
@@ -73,10 +81,27 @@ version = "9.0"
source = "https://ffmpeg.org/releases/ffmpeg-9.0.tar.xz"
sha256 = "7f607a00dd0d28a729d5a4811205812eef01cf6ef6155025febb6f36a9062d52"
license = "LGPLv2.1-or-later"
-linkage = "dynamic private libavcodec, libavutil, and libswscale"
-build = "H.264 and MJPEG decoders, H.264 parser, and H.264 V4L2 request hwaccel; no programs, network support, GPL, or nonfree components"
+linkage = "dynamic private libavcodec, libavformat, libavutil, libswresample, and libswscale"
+build = "H.264, MJPEG, PNG, and AAC decoders; MP4 demuxer; H.264 parser and V4L2 request hwaccel; no programs, network support, GPL, or nonfree components"
status = "source-built by tools/build-dependencies.sh and shipped with the port"
+[dep.expat]
+version = "2.8.5"
+source = "https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz"
+sha256 = "1e727b8933ec51a77a9a9d9afcf8e688bce45d907c13e36ab7393fe36e703182"
+license = "MIT"
+linkage = "static"
+build = "XML parser with DTD support disabled"
+status = "source-built by tools/build-dependencies.sh"
+
+[dep.zlib]
+version = "1.3.2"
+source = "https://zlib.net/zlib-1.3.2.tar.gz"
+sha256 = "bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16"
+license = "Zlib"
+linkage = "static into FFmpeg"
+status = "source-built by tools/build-dependencies.sh"
+
[dep.ffmpeg_v4l2_request]
source = "https://raw.githubusercontent.com/ROCKNIX/distribution/e9e6b8531df13bc9058ca1771dab5f0c4fd5e98e/packages/multimedia/ffmpeg/patches/v4l2-request/0001-v4l2-request.patch"
commit = "e9e6b8531df13bc9058ca1771dab5f0c4fd5e98e"