Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[submodule "vendor/libdatachannel"]
path = vendor/libdatachannel
url = https://github.com/paullouisageneau/libdatachannel.git
[submodule "vendor/mpp"]
path = vendor/mpp
url = https://github.com/rockchip-linux/mpp.git
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<img width="640" height="480" alt="screenshot" src="https://github.com/user-attachments/assets/4ec93eeb-8795-44e5-9ba8-c6065adf4660" />

GreenOvercast is a native Xbox Cloud Gaming client for small ARM64 Linux
handhelds. It streams at 720p and uses the H700 video decoder.
handhelds. It streams at 720p and uses hardware video decoding on supported
H700 and Rockchip firmware.

Built for the Anbernic RG35XX-H (muOS) and validated on the RG40XX-H (Knulli).

Expand All @@ -24,7 +25,7 @@ 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. Knulli asks you to sign in again after a reboot.

GreenOvercast is experimental and has only been tested on the devices below.
GreenOvercast is experimental.

## Controls

Expand Down Expand Up @@ -54,8 +55,9 @@ support 16:9.
| RG35XX-H | muOS 2508.4 | Tested |
| RG40XX-H | Knulli (Batocera 42) | Tested |

Hardware decoding currently only works on H700 devices. Other devices fall
back to software decoding, which is too slow for normal gameplay.
Rockchip MPP decoding also works on ROCKNIX, but support depends on the device

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The README now announces Rockchip/ROCKNIX MPP support, but the Supported devices table and the intro still list only H700 devices on muOS/Knulli, so no Rockchip device or ROCKNIX row exists for users to check against. The added claim also conflicts with the intro, which says the RG40XX-H was validated on Knulli even though the PR was tested on Rocknix. Add a Rockchip device/firmware row to the table (and align the intro's validation OS) so the new claim is reflected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 58:

<comment>The README now announces Rockchip/ROCKNIX MPP support, but the Supported devices table and the intro still list only H700 devices on muOS/Knulli, so no Rockchip device or ROCKNIX row exists for users to check against. The added claim also conflicts with the intro, which says the RG40XX-H was validated on Knulli even though the PR was tested on Rocknix. Add a Rockchip device/firmware row to the table (and align the intro's validation OS) so the new claim is reflected.</comment>

<file context>
@@ -54,8 +55,9 @@ support 16:9.
 
-Hardware decoding currently only works on H700 devices. Other devices fall
-back to software decoding, which is too slow for normal gameplay.
+Rockchip MPP decoding also works on ROCKNIX, but support depends on the device
+and firmware. Other devices fall back to software decoding, which is too slow
+for normal gameplay.
</file context>

and firmware. 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.
Expand Down
1 change: 1 addition & 0 deletions THIRDPARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ release. Full license texts for the bundled libraries ship alongside the port in
| [nlohmann/json](https://github.com/nlohmann/json) | MIT | JSON support used by libdatachannel (header-only) |
| [plog](https://github.com/SergiusTheBest/plog) | MIT | Logging used by libdatachannel (header-only) |
| [CedarX](https://github.com/allwinner-zh/media-codec) (Allwinner H.264 decoder) | LGPLv2.1-or-later | Hardware H.264 decode (shared) |
| [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 | Software H.264 fallback and color conversion (shared) |
| [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) |
Expand Down
159 changes: 151 additions & 8 deletions build.zig
Original file line number Diff line number Diff line change
@@ -1,29 +1,78 @@
const std = @import("std");

// H700 target profile: both devices run vendor kernel 4.9.170 with glibc
// 2.38 (muOS) / 2.40 (Knulli). Targeting the 2.38 ceiling covers both.
const h700_query: std.Target.Query = .{
// glibc 2.38 is the oldest userspace supported by the packaged release.
const aarch64_linux_query: std.Target.Query = .{
.cpu_arch = .aarch64,
.cpu_model = .{ .explicit = &std.Target.aarch64.cpu.cortex_a53 },
.os_tag = .linux,
.abi = .gnu,
.glibc_version = .{ .major = 2, .minor = 38, .patch = 0 },
};

const c_test_flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror" };

fn addHostZigObject(b: *std.Build, name: []const u8, source: []const u8) *std.Build.Step.Compile {
const module = b.createModule(.{
.root_source_file = b.path(source),
.target = b.graph.host,
.optimize = .Debug,
.link_libc = true,
});
module.addIncludePath(b.path("src/media/video"));
return b.addObject(.{ .name = name, .root_module = module });
}

fn addHostCExecutable(
b: *std.Build,
name: []const u8,
sources: []const []const u8,
objects: []const *std.Build.Step.Compile,
link_dl: bool,
) *std.Build.Step.Compile {
const executable = b.addExecutable(.{
.name = name,
.root_source_file = null,
.target = b.graph.host,
.optimize = .Debug,
});
executable.addIncludePath(b.path("src/media/video"));
executable.addCSourceFiles(.{ .files = sources, .flags = c_test_flags });
for (objects) |object| executable.addObject(object);
executable.linkLibC();
if (link_dl) executable.linkSystemLibrary("dl");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On macOS hosts, this unconditional linkSystemLibrary("dl") breaks build test for the new loader/decoder tests because macOS does not provide a separate libdl. Only link dl when the host OS is Linux.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At build.zig, line 41:

<comment>On macOS hosts, this unconditional `linkSystemLibrary("dl")` breaks `build test` for the new loader/decoder tests because macOS does not provide a separate `libdl`. Only link `dl` when the host OS is Linux.</comment>

<file context>
@@ -1,29 +1,78 @@
+    executable.addCSourceFiles(.{ .files = sources, .flags = c_test_flags });
+    for (objects) |object| executable.addObject(object);
+    executable.linkLibC();
+    if (link_dl) executable.linkSystemLibrary("dl");
+    return executable;
+}
</file context>
Suggested change
if (link_dl) executable.linkSystemLibrary("dl");
if (link_dl and b.graph.host.result.os.tag == .linux) executable.linkSystemLibrary("dl");

return executable;
}

fn addHostCFakeLibrary(
b: *std.Build,
name: []const u8,
source: []const u8,
) *std.Build.Step.Compile {
const library = b.addSharedLibrary(.{
.name = name,
.root_source_file = null,
.target = b.graph.host,
.optimize = .Debug,
});
library.addIncludePath(b.path("src/media/video"));
library.addCSourceFile(.{ .file = b.path(source), .flags = c_test_flags });
library.linkLibC();
return library;
}

pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe });
const h700 = b.resolveTargetQuery(h700_query);
const aarch64_linux = b.resolveTargetQuery(aarch64_linux_query);

const stat_compat = b.createModule(.{
.root_source_file = b.path("src/platform/linux/stat_compat.zig"),
.target = h700,
.target = aarch64_linux,
.optimize = optimize,
});

const smoke = b.addExecutable(.{
.name = "greenovercast-smoke",
.root_source_file = b.path("src/smoke/abi_smoke.zig"),
.target = h700,
.target = aarch64_linux,
.optimize = optimize,
.link_libc = true,
});
Expand All @@ -46,6 +95,9 @@ pub fn build(b: *std.Build) void {
.{ .name = "greenovercast-cloud", .path = "src/session/cloud_session.zig" },
.{ .name = "greenovercast-controller", .path = "src/input/controller.zig" },
.{ .name = "greenovercast-ui", .path = "src/ui/handheld_ui.zig" },
.{ .name = "greenovercast-video-decoder", .path = "src/media/video/video_decoder.zig" },
.{ .name = "greenovercast-video-decoder-selection", .path = "src/media/video/video_decoder_selection.zig" },
.{ .name = "greenovercast-video-frame-copy", .path = "src/media/video/video_frame_copy.zig" },
};
const product_include_paths = [_][]const u8{
"vendor/headers",
Expand All @@ -63,7 +115,7 @@ pub fn build(b: *std.Build) void {
for (product_roots) |root| {
const module = b.createModule(.{
.root_source_file = b.path(root.path),
.target = h700,
.target = aarch64_linux,
.optimize = .ReleaseSafe,
.link_libc = true,
});
Expand Down Expand Up @@ -97,4 +149,95 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(unit_tests).step);
}

const video_decoder_object =
addHostZigObject(b, "video-decoder", "src/media/video/video_decoder.zig");
const video_frame_copy_object =
addHostZigObject(b, "video-frame-copy", "src/media/video/video_frame_copy.zig");
const video_decoder_selection_object = addHostZigObject(
b,
"video-decoder-selection",
"src/media/video/video_decoder_selection.zig",
);

const decoder_contract_test = addHostCExecutable(
b,
"video-decoder-test",
&.{"tests/video_decoder_test.c"},
&.{video_decoder_object},
false,
);
test_step.dependOn(&b.addRunArtifact(decoder_contract_test).step);

const decoder_selection_test = addHostCExecutable(
b,
"video-decoder-selection-test",
&.{"tests/video_decoder_selection_test.c"},
&.{ video_decoder_object, video_decoder_selection_object },
false,
);
test_step.dependOn(&b.addRunArtifact(decoder_selection_test).step);

const frame_copy_test = addHostCExecutable(
b,
"video-frame-copy-test",
&.{"tests/video_frame_copy_test.c"},
&.{video_frame_copy_object},
false,
);
test_step.dependOn(&b.addRunArtifact(frame_copy_test).step);

const fake_cedar_valid =
addHostCFakeLibrary(b, "fake-cedar-valid", "tests/cedar_fake_valid.c");
const cedar_decoder_test = addHostCExecutable(
b,
"video-decoder-cedar-test",
&.{
"src/media/video/cedar_loader.c",
"src/media/video/video_decoder_cedar.c",
"tests/video_decoder_cedar_test.c",
},
&.{video_decoder_object},
true,
);
const run_cedar_decoder_test = b.addRunArtifact(cedar_decoder_test);
run_cedar_decoder_test.addArtifactArg(fake_cedar_valid);
test_step.dependOn(&run_cedar_decoder_test.step);

const fake_mpp_valid = addHostCFakeLibrary(b, "fake-mpp-valid", "tests/mpp_fake_valid.c");
const fake_mpp_wrong_abi =
addHostCFakeLibrary(b, "fake-mpp-wrong-abi", "tests/mpp_fake_wrong_abi.c");
const fake_mpp_missing_symbol =
addHostCFakeLibrary(b, "fake-mpp-missing-symbol", "tests/mpp_fake_missing_symbol.c");

const mpp_loader_test = addHostCExecutable(
b,
"mpp-loader-test",
&.{
"src/media/video/mpp_loader.c",
"tests/mpp_loader_test.c",
},
&.{},
true,
);
const run_mpp_loader_test = b.addRunArtifact(mpp_loader_test);
run_mpp_loader_test.addArtifactArg(fake_mpp_valid);
run_mpp_loader_test.addArtifactArg(fake_mpp_wrong_abi);
run_mpp_loader_test.addArtifactArg(fake_mpp_missing_symbol);
test_step.dependOn(&run_mpp_loader_test.step);

const mpp_decoder_test = addHostCExecutable(
b,
"video-decoder-mpp-test",
&.{
"src/media/video/mpp_loader.c",
"src/media/video/video_decoder_mpp.c",
"tests/video_decoder_mpp_test.c",
},
&.{video_decoder_object},
true,
);
const run_mpp_decoder_test = b.addRunArtifact(mpp_decoder_test);
run_mpp_decoder_test.addArtifactArg(fake_mpp_valid);
test_step.dependOn(&run_mpp_decoder_test.step);
}
17 changes: 17 additions & 0 deletions packaging/portmaster/greenovercast/GreenOvercast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,26 @@ export GREENOVERCAST_TOKEN_KEY_FILE="$credential_key_file"
export GREENOVERCAST_H264_BOOTSTRAP_FILE="$video_bootstrap_file"
export GREENOVERCAST_CATALOG_FILE="$catalog_file"
export GREENOVERCAST_CEDAR_LIBRARY="$GAMEDIR/libgreenovercast-cedar.so"
export GREENOVERCAST_MPP_LIBRARY="$GAMEDIR/libgreenovercast-mpp.so"
export LD_LIBRARY_PATH="$GAMEDIR:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig"

if [ -e /dev/vpu_service ] && { [ ! -r /dev/vpu_service ] || [ ! -w /dev/vpu_service ]; }; then
$ESUDO chgrp video /dev/vpu_service && $ESUDO chmod 660 /dev/vpu_service
fi

for compatible_file in /proc/device-tree/compatible /sys/firmware/devicetree/base/compatible; do
if [ -r "$compatible_file" ] && tr '\0' '\n' <"$compatible_file" | grep -q '^rockchip,'; then
$ESUDO chmod +x "$GAMEDIR/rockchip/greenovercast-mpp-probe.aarch64"
if ! GREENOVERCAST_MPP_LIBRARY="$GREENOVERCAST_MPP_LIBRARY" \
LD_LIBRARY_PATH="$LD_LIBRARY_PATH" \
"$GAMEDIR/rockchip/greenovercast-mpp-probe.aarch64" >/dev/null 2>&1; then
export LD_LIBRARY_PATH="$GAMEDIR/rockchip:$LD_LIBRARY_PATH"
fi
break
fi
done

if [ -S /var/run/pipewire-0 ]; then
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/var/run}"
export PIPEWIRE_RUNTIME_DIR="${PIPEWIRE_RUNTIME_DIR:-/var/run}"
Expand Down
40 changes: 34 additions & 6 deletions src/app/release.zig
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,23 @@ pub const Release = struct {

fn initializeMedia(self: *Release) bool {
const bootstrap_path = std.posix.getenv("GREENOVERCAST_H264_BOOTSTRAP_FILE");
self.video = c.go_video_pipeline_create(
c.go_sdl_platform_renderer(self.platform),
if (bootstrap_path) |path| path.ptr else null,
);
const decoder_value = std.posix.getenv("GREENOVERCAST_VIDEO_DECODER");
var decoder_preference: c.GoVideoDecoderPreference = c.GO_VIDEO_DECODER_PREFERENCE_AUTO;
if (c.go_video_decoder_preference_parse(
if (decoder_value) |value| value.ptr else null,
&decoder_preference,
) != 0) {
std.debug.print("Invalid GREENOVERCAST_VIDEO_DECODER value\n", .{});
return false;
}
const config = c.GoVideoPipelineConfig{
.renderer = c.go_sdl_platform_renderer(self.platform),
.bootstrap_path = if (bootstrap_path) |path| path.ptr else null,
.max_width = 1280,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a decoded frame exceeds 1280×720, this configuration rejects it regardless of the negotiated stream size. The pipeline then treats the rejection as decoder failure, falls back to software, and reaches terminal failure; use negotiated dimensions or handle this case separately.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/release.zig, line 81:

<comment>When a decoded frame exceeds 1280×720, this configuration rejects it regardless of the negotiated stream size. The pipeline then treats the rejection as decoder failure, falls back to software, and reaches terminal failure; use negotiated dimensions or handle this case separately.</comment>

<file context>
@@ -66,10 +66,23 @@ pub const Release = struct {
+        const config = c.GoVideoPipelineConfig{
+            .renderer = c.go_sdl_platform_renderer(self.platform),
+            .bootstrap_path = if (bootstrap_path) |path| path.ptr else null,
+            .max_width = 1280,
+            .max_height = 720,
+            .decoder_preference = decoder_preference,
</file context>

.max_height = 720,
.decoder_preference = decoder_preference,
};
self.video = c.go_video_pipeline_create(&config);
if (self.video == null or c.go_video_pipeline_start(self.video) < 0) {
std.debug.print("H.264 pipeline initialization failed\n", .{});
_ = c.go_video_pipeline_destroy(self.video);
Expand Down Expand Up @@ -379,6 +392,8 @@ pub const Release = struct {
std.debug.print("Cloud game ended\n", .{});
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);
Expand Down Expand Up @@ -417,8 +432,8 @@ pub const Release = struct {
std.debug.print(
"[{d}s] video_rtp={d} payload={d} rejected={d}/pt{d} aus={d} frames={d}/{d} " ++
"nals={d}/{d}/{d}/{d} ts={d} synced={d} gaps={d} missing={d} late_rtp={d} " ++
"decode_errors={d}/{d}/{d} keyframes={d} queue={d}/{d} audio_rtp={d} decoded={d} " ++
"dropped={d} late={d} pending={d} queued_ms={d} resets={d} keepalive={d}/{d}\n",
"decoder={d} init_failures={d} fallbacks={d} backpressure={d} corrupt={d} " ++
"info_changes={d} decode_errors={d}/{d}/{d} keyframes={d} queue={d}/{d}\n",
.{
elapsed_ms / 1000,
video.rtp_packets,
Expand All @@ -437,12 +452,25 @@ pub const Release = struct {
video.discontinuities,
video.missing_packets,
video.late_packets,
video.decoder_backend,
video.decoder_init_failures,
video.decoder_runtime_fallbacks,
video.decoder_backpressure_events,
video.decoder_corrupt_frames,
video.decoder_info_changes,
video.decoder_send_errors,
video.decoder_receive_errors,
video.last_decoder_error,
video.keyframe_requests,
video.pending_packets,
video.dropped_packets,
},
);
std.debug.print(
"[{d}s] audio_rtp={d} decoded={d} dropped={d} late={d} pending={d} " ++
"queued_ms={d} resets={d} keepalive={d}/{d}\n",
.{
elapsed_ms / 1000,
audio.rtp_packets,
audio.decoded_packets,
audio.dropped_packets,
Expand Down
7 changes: 4 additions & 3 deletions src/media/rtp/h264_depacketizer.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ int go_h264_depacketizer_set_bootstrap(GoH264Depacketizer* depacketizer, const u
size_t length);
int go_h264_depacketizer_take_bootstrap(GoH264Depacketizer* depacketizer, uint8_t* output,
size_t capacity, size_t* length);
GoH264FeedResult go_h264_depacketizer_feed(GoH264Depacketizer* depacketizer,
const uint8_t* packet, size_t length,
GoH264AccessUnitCallback callback, void* context);
void go_h264_depacketizer_restart_decode_epoch(GoH264Depacketizer* depacketizer);
GoH264FeedResult go_h264_depacketizer_feed(GoH264Depacketizer* depacketizer, const uint8_t* packet,
size_t length, GoH264AccessUnitCallback callback,
void* context);

#ifdef __cplusplus
}
Expand Down
Loading
Loading