(d)?.requestFocus() }
+ tab != Tab.HOME -> { FeelSounds.play(this, FeelSounds.Cue.BACK); go(Tab.HOME) }
+ else -> { FeelSounds.play(this, FeelSounds.Cue.BACK); moveTaskToBack(true) } // keep listening for the phone
+ }
+ }
+
+ private fun rounded(r: Number) = object : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) = outline.setRoundRect(0, 0, view.width, view.height, tv.pxf(r))
+ }
+
+ companion object {
+ private const val Service_BIND = Context.BIND_AUTO_CREATE
+ private const val REQ_PSN_REGISTER = 41
+ private const val REQ_PSN = 42
+ }
+}
diff --git a/app/src/tv/java/com/limelight/home/PhoneLinkService.kt b/app/src/tv/java/com/limelight/home/PhoneLinkService.kt
new file mode 100644
index 0000000000..8c98ed024c
--- /dev/null
+++ b/app/src/tv/java/com/limelight/home/PhoneLinkService.kt
@@ -0,0 +1,88 @@
+package com.limelight.home
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import com.limelight.PhoneLink
+import com.limelight.R
+
+/**
+ * Keeps the phone's channel (:8099) open whether or not MoonCast is on screen, from boot onwards, so the Odin or a
+ * phone always finds this TV and can start a game on it. A foreground service, so Android doesn't stop it in the
+ * background. The home screen, when it's showing, listens in to show "Sent from …".
+ */
+class PhoneLinkService : Service(), PhoneLink.Listener {
+ private var link: PhoneLink? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ goForeground()
+ link = PhoneLink(this, this).also { it.start() }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
+
+ override fun onDestroy() {
+ link?.stop()
+ link = null
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onIncoming(title: String, from: String) { listener?.onIncoming(title, from) }
+ override fun onConsolesChanged() { listener?.onConsolesChanged() }
+ override fun onRegistering(host: String) { listener?.onRegistering(host) }
+ override fun onRegistered(host: String, name: String?) { listener?.onRegistered(host, name) }
+
+ private fun goForeground() {
+ // the name-based lookup: getSystemService(Class) is Android 6+, and the TV app runs on Android 5 sticks too
+ val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ val b = if (Build.VERSION.SDK_INT >= 26) {
+ nm.createNotificationChannel(NotificationChannel(CHANNEL, "Ready for your phone", NotificationManager.IMPORTANCE_MIN).apply {
+ description = "Keeps MoonCast TV reachable so your phone can start games here"
+ setShowBadge(false)
+ })
+ Notification.Builder(this, CHANNEL)
+ } else @Suppress("DEPRECATION") Notification.Builder(this).setPriority(Notification.PRIORITY_MIN)
+ val n = b.setSmallIcon(R.drawable.ic_play)
+ .setContentTitle("MoonCast TV")
+ .setContentText("Ready for your phone")
+ .setOngoing(true)
+ .build()
+ if (Build.VERSION.SDK_INT >= 34) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
+ else startForeground(ID, n)
+ }
+
+ companion object {
+ private const val CHANNEL = "mc_phone_link"
+ private const val ID = 8099
+
+ /** The home screen while it's showing; gets "Sent from …" and console changes. */
+ @Volatile var listener: PhoneLink.Listener? = null
+
+ fun start(ctx: Context) {
+ val i = Intent(ctx, PhoneLinkService::class.java)
+ try {
+ if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
+ } catch (e: Exception) { android.util.Log.w("MoonCast", "phone link service: $e") }
+ }
+ }
+}
+
+/** Start listening for the phone as soon as the TV boots (and after an app update). */
+class PhoneLinkBoot : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED, "android.intent.action.QUICKBOOT_POWERON" ->
+ PhoneLinkService.start(ctx)
+ }
+ }
+}
diff --git a/doc/MOONCAST-TV.md b/doc/MOONCAST-TV.md
new file mode 100644
index 0000000000..72461618f1
--- /dev/null
+++ b/doc/MOONCAST-TV.md
@@ -0,0 +1,32 @@
+# MoonCast TV — native Android TV client (fork of moonlight-android)
+
+This is MoonCast's native TV client, forked from the excellent
+[moonlight-android](https://github.com/moonlight-stream/moonlight-android) (GPL-3). Moonlight already does the hard
+part perfectly: **native MediaCodec hardware decode + Sunshine's own low-latency protocol** (`NvConnection`, RTSP,
+ENet, via the `moonlight-common-c` native lib). That is why, on the same Mi TV Stick where a cast-browser page managed
+~28 fps and seconds of lag, native Moonlight does 1080p60 with ~15 ms latency (measured 2026-10-15).
+
+## Why fork rather than keep the WebView app
+MoonCast's first TV app was a thin shell around the system WebView loading the helper stream. It fixed the picture
+(hardware WebCodecs, 1080p60) but inherits the browser WebRTC stack. The native client removes that layer entirely.
+
+## The two transports (the plan)
+| Transport | When | How |
+|---|---|---|
+| **Native (this app, as-is)** | the PC is reachable: home LAN, or JAL over the home↔JAL WireGuard link | Moonlight's own protocol straight to Sunshine. Lowest latency. |
+| **Helper fallback** | strict NAT / the PC is not directly reachable | the MoonCast PC helper (WebRTC P2P, no port forwarding). To be added. |
+
+The phone stays the controller in both (MoonCast's InputSession), and launches the TV app by code / LAN discovery
+(the MoonCast TV LAN listener is already in the WebView app; it will move here).
+
+## Status
+- [x] Forked to `MoonCastDev/moonlight-android`, submodules in.
+- [x] Rebranded: applicationId `me.mooncast.tv`, app name **MoonCast TV**, own id (not Moonlight's, as upstream asks).
+- [ ] Icon / banner (MoonCast blood-moon).
+- [ ] Build + install, confirm native 1080p60 on the stick and the JAL TV.
+- [ ] Helper fallback transport.
+- [ ] Launch-from-phone (LAN discovery + the `/play` listener) ported from the WebView app.
+- [ ] Release signing with MoonCast's key.
+
+## Credit
+Built on moonlight-stream/moonlight-android (GPL-3). Huge thanks to the Moonlight authors.
diff --git a/docs/mockups/about/about.png b/docs/mockups/about/about.png
new file mode 100644
index 0000000000..41b40a6215
Binary files /dev/null and b/docs/mockups/about/about.png differ
diff --git a/docs/mockups/about/licences.png b/docs/mockups/about/licences.png
new file mode 100644
index 0000000000..cfc38a4004
Binary files /dev/null and b/docs/mockups/about/licences.png differ
diff --git a/docs/mockups/fsr/compare.html b/docs/mockups/fsr/compare.html
new file mode 100644
index 0000000000..6778bca861
--- /dev/null
+++ b/docs/mockups/fsr/compare.html
@@ -0,0 +1,217 @@
+
+Super resolution: a 720p stream on a 2448 × 1696 screen
+The same frame, enlarged 3.4×; a close-up of the middle of the picture
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/mockups/fsr/fsr_compare.png b/docs/mockups/fsr/fsr_compare.png
new file mode 100644
index 0000000000..030451f3a4
Binary files /dev/null and b/docs/mockups/fsr/fsr_compare.png differ
diff --git a/docs/mockups/fsr/fsr_settings.png b/docs/mockups/fsr/fsr_settings.png
new file mode 100644
index 0000000000..4bec8357c9
Binary files /dev/null and b/docs/mockups/fsr/fsr_settings.png differ
diff --git a/docs/mockups/onscreen-pad/v10_PlayStation.png b/docs/mockups/onscreen-pad/v10_PlayStation.png
new file mode 100644
index 0000000000..b52648677d
Binary files /dev/null and b/docs/mockups/onscreen-pad/v10_PlayStation.png differ
diff --git a/docs/mockups/onscreen-pad/v11_PlayStation.png b/docs/mockups/onscreen-pad/v11_PlayStation.png
new file mode 100644
index 0000000000..a9c9174b91
Binary files /dev/null and b/docs/mockups/onscreen-pad/v11_PlayStation.png differ
diff --git a/docs/mockups/onscreen-pad/v12_PlayStation.png b/docs/mockups/onscreen-pad/v12_PlayStation.png
new file mode 100644
index 0000000000..c68829fcd2
Binary files /dev/null and b/docs/mockups/onscreen-pad/v12_PlayStation.png differ
diff --git a/docs/mockups/onscreen-pad/v13_PlayStation.png b/docs/mockups/onscreen-pad/v13_PlayStation.png
new file mode 100644
index 0000000000..0b88cad8cc
Binary files /dev/null and b/docs/mockups/onscreen-pad/v13_PlayStation.png differ
diff --git a/docs/mockups/onscreen-pad/v14_PlayStation.png b/docs/mockups/onscreen-pad/v14_PlayStation.png
new file mode 100644
index 0000000000..04fc6e5b92
Binary files /dev/null and b/docs/mockups/onscreen-pad/v14_PlayStation.png differ
diff --git a/docs/mockups/onscreen-pad/v8_PlayStation.png b/docs/mockups/onscreen-pad/v8_PlayStation.png
new file mode 100644
index 0000000000..dc1c644e80
Binary files /dev/null and b/docs/mockups/onscreen-pad/v8_PlayStation.png differ
diff --git a/docs/mockups/onscreen-pad/v9_PlayStation.png b/docs/mockups/onscreen-pad/v9_PlayStation.png
new file mode 100644
index 0000000000..a04e03fdf5
Binary files /dev/null and b/docs/mockups/onscreen-pad/v9_PlayStation.png differ
diff --git a/docs/mockups/onscreen-pad/vn1_Switch.png b/docs/mockups/onscreen-pad/vn1_Switch.png
new file mode 100644
index 0000000000..6ee18f6e37
Binary files /dev/null and b/docs/mockups/onscreen-pad/vn1_Switch.png differ
diff --git a/docs/mockups/onscreen-pad/vx1_Xbox.png b/docs/mockups/onscreen-pad/vx1_Xbox.png
new file mode 100644
index 0000000000..abfc285c8f
Binary files /dev/null and b/docs/mockups/onscreen-pad/vx1_Xbox.png differ
diff --git a/docs/mockups/onscreen-pad/vx2_Xbox.png b/docs/mockups/onscreen-pad/vx2_Xbox.png
new file mode 100644
index 0000000000..6a9918a05e
Binary files /dev/null and b/docs/mockups/onscreen-pad/vx2_Xbox.png differ
diff --git a/gradle.properties b/gradle.properties
index e0212117d6..402ec180b0 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,10 +10,13 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx10248m -XX:MaxPermSize=256m
-# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
-org.gradle.jvmargs=-Xmx3072m
+org.gradle.jvmargs=-Xmx4g
+org.gradle.jvmargs=-Xmx4g
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
\ No newline at end of file
+# org.gradle.parallel=true
+org.gradle.workers.max=1
+org.gradle.parallel=false
+org.gradle.configureondemand=false
diff --git a/native/build-nano.sh b/native/build-nano.sh
new file mode 100755
index 0000000000..2d6c7e000f
--- /dev/null
+++ b/native/build-nano.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Build XStreaming nano-rs (Xbox Remote Play engine) for Android. Usage: build-nano.sh
+set -e
+export RUSTUP_HOME=/root/mcbuild/rust/rustup CARGO_HOME=/root/mcbuild/rust/cargo PATH=/root/mcbuild/rust/cargo/bin:$PATH
+NDK=$(ls -d /root/mcbuild/sdk/ndk/* | tail -1); TC=$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin
+T=$1; P=$2; U=$(echo $T | tr a-z- A-Z_); L=$(echo $T | tr - _)
+export CARGO_TARGET_${U}_LINKER=$TC/${P}23-clang CC_$L=$TC/${P}23-clang AR_$L=$TC/llvm-ar
+cd /root/mcbuild/xstreaming/nano-rs
+nice -n 10 cargo build --target $T --release -j 6
+ls -la target/$T/release/libnano_rs.so
diff --git a/native/mc_curl_ca_patch.py b/native/mc_curl_ca_patch.py
new file mode 100644
index 0000000000..bed0c69203
--- /dev/null
+++ b/native/mc_curl_ca_patch.py
@@ -0,0 +1,28 @@
+import sys
+p = sys.argv[1]
+s = open(p).read()
+if "mc_curl_easy_init" in s:
+ print("already patched"); sys.exit(0)
+inc = "#include "
+assert s.count(inc) == 1
+wrapper = inc + r"""
+
+#ifdef __ANDROID__
+#include
+// MoonCast: Android's libcurl has no CA store, so every PSN HTTPS call failed with "SSL peer certificate
+// ... was not OK". The app writes the device's trusted roots (AndroidCAStore) to a PEM file and exports its
+// path as MC_CA_BUNDLE; give it to every curl handle here (the AIA chain repair below builds on CURLINFO_CAINFO).
+static CURL *mc_curl_easy_init(void)
+{
+ CURL *c = curl_easy_init();
+ const char *ca = getenv("MC_CA_BUNDLE");
+ if(c && ca && *ca)
+ curl_easy_setopt(c, CURLOPT_CAINFO, ca);
+ return c;
+}
+#define curl_easy_init mc_curl_easy_init
+#endif
+"""
+s = s.replace(inc, wrapper, 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_curl_ca_patch2.py b/native/mc_curl_ca_patch2.py
new file mode 100644
index 0000000000..c73858e9ae
--- /dev/null
+++ b/native/mc_curl_ca_patch2.py
@@ -0,0 +1,40 @@
+import sys
+root = sys.argv[1] # chiaki-ng/lib/src
+
+# 1) aia_openssl.c: the chain-repair trust store asks curl for CURLINFO_CAINFO, which is curl's *built-in*
+# default (empty on Android), not CURLOPT_CAINFO -> fall back to the app's MC_CA_BUNDLE.
+p = root + "/aia_openssl.c"
+s = open(p).read()
+if "MC_CA_BUNDLE" not in s:
+ a = """ goto error;
+#ifdef _WIN32"""
+ assert s.count(a) == 1
+ s = s.replace(a, """ goto error;
+#ifdef __ANDROID__
+ if(!file && !path)
+ file = getenv("MC_CA_BUNDLE"); // MoonCast: Android curl has no built-in CA path
+#endif
+#ifdef _WIN32""", 1)
+ if "#include " not in s:
+ s = "#include \n" + s
+ open(p, "w").write(s)
+ print("aia_openssl.c patched")
+else:
+ print("aia_openssl.c already patched")
+
+# 2) holepunch.c: the CAINFO_BLOB it builds (which *replaces* CAINFO) starts from CURLINFO_CAINFO's file ->
+# same fallback, so the repaired chain keeps the device roots.
+p = root + "/remote/holepunch.c"
+s = open(p).read()
+if "MC_CA_BUNDLE (aia blob)" not in s:
+ a = "CURLcode ca_info_res = curl_easy_getinfo(curl, CURLINFO_CAINFO, &ca_path);"
+ assert s.count(a) == 1
+ s = s.replace(a, a + """
+#ifdef __ANDROID__
+ if(ca_info_res == CURLE_OK && !ca_path)
+ ca_path = getenv("MC_CA_BUNDLE"); // MoonCast: MC_CA_BUNDLE (aia blob)
+#endif""", 1)
+ open(p, "w").write(s)
+ print("holepunch.c patched")
+else:
+ print("holepunch.c already patched")
diff --git a/native/mc_decoder_nullbuf_patch.py b/native/mc_decoder_nullbuf_patch.py
new file mode 100644
index 0000000000..9699fe3660
--- /dev/null
+++ b/native/mc_decoder_nullbuf_patch.py
@@ -0,0 +1,22 @@
+import sys
+# MoonCast (#37): when the screen surface goes away under a running decoder, the codec errors out and
+# AMediaCodec_getInputBuffer() returns NULL; chiaki then copied the frame into it (SIGSEGV in video_sample, seen on the
+# Mi stick in test177). Drop the frame instead. Run after mc_decoder_shutdown_patch.py, with
+# android/app/src/main/cpp/video-decoder.c as the argument.
+p = sys.argv[1]
+s = open(p).read()
+if "MoonCast (#37): no input buffer" in s:
+ print("already patched"); sys.exit(0)
+old = """ uint8_t *codec_buf = AMediaCodec_getInputBuffer(decoder->codec, (size_t)codec_buf_index, &codec_buf_size);
+"""
+new = old + """ if(!codec_buf) // MoonCast (#37): no input buffer, the codec has failed (e.g. its surface is gone)
+ {
+ CHIAKI_LOGE(decoder->log, "AMediaCodec_getInputBuffer() returned no buffer");
+ r = false;
+ goto beach;
+ }
+"""
+assert s.count(old) == 1, "getInputBuffer call not found"
+s = s.replace(old, new, 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_decoder_shutdown_patch.py b/native/mc_decoder_shutdown_patch.py
new file mode 100644
index 0000000000..6920a73864
--- /dev/null
+++ b/native/mc_decoder_shutdown_patch.py
@@ -0,0 +1,65 @@
+import re, sys
+# MoonCast (#37): a clean decoder shutdown for chiaki's Android video decoder. Run after mc_surface_lock_patch.py, with
+# android/app/src/main/cpp/video-decoder.c as the argument. Replaces mc_decoder_join_patch.py (test176), which only
+# fixed part of it.
+#
+# On Android 9 (the Mi stick) any AMediaCodec dequeue after AMediaCodec_stop() posts a second activity request to
+# MediaCodec and it aborts the app (F/MediaCodec CHECK(mActivityNotify == NULL)). chiaki's kill_decoder() stopped the
+# codec first and only then let the other threads go: the stream thread, waiting on codec_mutex, fed one more frame
+# into the stopped codec, and the output thread's endless dequeue returned after the stop. Ending a PS5 game then
+# crashed the app at random (2 of 3 stops in testing).
+# Fix: stop feeding and drain first, stop and delete last.
+# - video_sample(): no input while shutting down.
+# - output thread: polls every 50 ms instead of waiting forever, so it sees shutdown_output and exits by itself.
+# - kill_decoder(): flag shutdown, send EOS if a buffer is free, let the output thread end, then stop and delete
+# the codec under the mutex, so no thread can call into it afterwards.
+p = sys.argv[1]
+s = open(p).read()
+if "MoonCast (#37): clean shutdown" in s:
+ print("already patched"); sys.exit(0)
+
+kill = re.compile(r"static void kill_decoder\(AndroidChiakiVideoDecoder \*decoder\)\n\{\n.*?\n\tdecoder->shutdown_output = false;\n\}\n", re.S)
+assert len(kill.findall(s)) == 1, "kill_decoder() not found"
+s = kill.sub(lambda m: """static void kill_decoder(AndroidChiakiVideoDecoder *decoder)
+{
+ // MoonCast (#37): clean shutdown. No AMediaCodec call may follow AMediaCodec_stop(): on Android 9 that aborts.
+ chiaki_mutex_lock(&decoder->codec_mutex);
+ decoder->shutdown_output = true; // video_sample() stops feeding, the output thread ends on its next poll
+ ssize_t codec_buf_index = AMediaCodec_dequeueInputBuffer(decoder->codec, 1000);
+ if(codec_buf_index >= 0)
+ {
+ CHIAKI_LOGI(decoder->log, "Video Decoder sending EOS buffer");
+ AMediaCodec_queueInputBuffer(decoder->codec, (size_t)codec_buf_index, 0, 0, decoder->timestamp_cur++, AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM);
+ }
+ else
+ CHIAKI_LOGE(decoder->log, "Failed to get input buffer for shutting down Video Decoder!");
+ chiaki_mutex_unlock(&decoder->codec_mutex);
+ chiaki_thread_join(&decoder->output_thread, NULL);
+ chiaki_mutex_lock(&decoder->codec_mutex);
+ AMediaCodec_stop(decoder->codec);
+ AMediaCodec_delete(decoder->codec);
+ decoder->codec = NULL;
+ decoder->shutdown_output = false;
+ chiaki_mutex_unlock(&decoder->codec_mutex);
+}
+""", s, count=1)
+
+old_feed = """ if(!decoder->codec)
+ {
+ CHIAKI_LOGE(decoder->log, "Received video data, but decoder is not initialized!");
+ goto beach;
+ }
+"""
+new_feed = old_feed + """ if(decoder->shutdown_output)
+ goto beach; // MoonCast (#37): shutting down, the codec takes no more input
+"""
+assert s.count(old_feed) == 1, "video_sample() check not found"
+s = s.replace(old_feed, new_feed, 1)
+
+old_out = "ssize_t status = AMediaCodec_dequeueOutputBuffer(decoder->codec, &info, -1);"
+new_out = "ssize_t status = AMediaCodec_dequeueOutputBuffer(decoder->codec, &info, 50000); // MoonCast (#37): poll, see shutdown_output"
+assert s.count(old_out) == 1, "output thread dequeue not found"
+s = s.replace(old_out, new_out, 1)
+
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_gotobed_patch.py b/native/mc_gotobed_patch.py
new file mode 100644
index 0000000000..9659c9ac05
--- /dev/null
+++ b/native/mc_gotobed_patch.py
@@ -0,0 +1,17 @@
+import sys
+# MoonCast: expose chiaki_session_goto_bed to Kotlin, so a player can put the PS5 into rest mode when you leave.
+p = sys.argv[1]
+s = open(p).read()
+if "JNI_FCN(sessionGotoBed)" in s:
+ print("already patched"); sys.exit(0)
+anchor = "JNIEXPORT void JNICALL JNI_FCN(sessionSetLoginPin)"
+assert anchor in s, "sessionSetLoginPin not found"
+s = s.replace(anchor, """JNIEXPORT jint JNICALL JNI_FCN(sessionGotoBed)(JNIEnv *env, jobject obj, jlong ptr)
+{
+ AndroidChiakiSession *session = (AndroidChiakiSession *)ptr;
+ return session ? chiaki_session_goto_bed(&session->session) : CHIAKI_ERR_UNINITIALIZED;
+}
+
+""" + anchor, 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_jni_patch.py b/native/mc_jni_patch.py
new file mode 100644
index 0000000000..5682761a13
--- /dev/null
+++ b/native/mc_jni_patch.py
@@ -0,0 +1,113 @@
+import sys
+p = sys.argv[1]
+s = open(p).read()
+if "mc_psn_connect" in s:
+ print("already patched"); sys.exit(0)
+
+helpers = r"""
+// ---- MoonCast: Remote Play over the internet (PSN hole-punch, no VPN / no port-forward) ----
+// Optional ConnectInfo field: absent in older Kotlin bindings -> NULL (LAN behaviour unchanged).
+static jobject mc_opt_field(JNIEnv *env, jobject obj, jclass cls, const char *name, const char *sig)
+{
+ jfieldID fid = E->GetFieldID(env, cls, name, sig);
+ if(E->ExceptionCheck(env)) { E->ExceptionClear(env); return NULL; }
+ return fid ? E->GetObjectField(env, obj, fid) : NULL;
+}
+
+static int mc_hex_nibble(char c)
+{
+ if(c >= '0' && c <= '9') return c - '0';
+ if(c >= 'a' && c <= 'f') return c - 'a' + 10;
+ if(c >= 'A' && c <= 'F') return c - 'A' + 10;
+ return -1;
+}
+
+// Same steps as chiaki-ng's StreamSession::InitiatePsnConnection + ConnectPsnConnection: PSN brokers the
+// connection and only the control hole is punched here - chiaki_session punches the data hole itself.
+static ChiakiHolepunchSession mc_psn_connect(ChiakiLog *log, const char *token, const char *duid_hex, bool ps5)
+{
+ size_t hex_len = strlen(duid_hex);
+ uint8_t duid[64];
+ if(hex_len == 0 || hex_len % 2 || hex_len / 2 > sizeof(duid))
+ {
+ CHIAKI_LOGE(log, "MoonCast PSN: invalid console duid");
+ return NULL;
+ }
+ for(size_t i = 0; i < hex_len / 2; i++)
+ {
+ int hi = mc_hex_nibble(duid_hex[2 * i]), lo = mc_hex_nibble(duid_hex[2 * i + 1]);
+ if(hi < 0 || lo < 0)
+ {
+ CHIAKI_LOGE(log, "MoonCast PSN: invalid console duid");
+ return NULL;
+ }
+ duid[i] = (uint8_t)((hi << 4) | lo);
+ }
+ ChiakiHolepunchSession hs = chiaki_holepunch_session_init(token, log);
+ if(!hs)
+ {
+ CHIAKI_LOGE(log, "MoonCast PSN: hole-punch session init failed (token?)");
+ return NULL;
+ }
+ // No UPnP probe: it only ever timed out (7 s per attempt) and the hole-punch works without it.
+ ChiakiErrorCode err = chiaki_holepunch_session_create(hs);
+ if(err == CHIAKI_ERR_SUCCESS)
+ err = holepunch_session_create_offer(hs);
+ if(err == CHIAKI_ERR_SUCCESS)
+ err = chiaki_holepunch_session_start(hs, duid, ps5 ? CHIAKI_HOLEPUNCH_CONSOLE_TYPE_PS5 : CHIAKI_HOLEPUNCH_CONSOLE_TYPE_PS4);
+ if(err == CHIAKI_ERR_SUCCESS)
+ err = chiaki_holepunch_session_punch_hole(hs, CHIAKI_HOLEPUNCH_PORT_TYPE_CTRL);
+ if(err != CHIAKI_ERR_SUCCESS)
+ {
+ CHIAKI_LOGE(log, "MoonCast PSN: hole-punch failed: %s", chiaki_error_string(err));
+ chiaki_holepunch_session_fini(hs);
+ return NULL;
+ }
+ CHIAKI_LOGI(log, "MoonCast PSN: control hole punched, starting session over the internet");
+ return hs;
+}
+
+"""
+anchor = "JNIEXPORT void JNICALL JNI_FCN(sessionCreate)"
+assert s.count(anchor) == 1
+s = s.replace(anchor, helpers + anchor, 1)
+
+block = r""" // MoonCast: Remote Play over the internet when the app passes a PSN token + console duid
+ {
+ jstring psn_token_s = mc_opt_field(env, connect_info_obj, connect_info_class, "psnToken", "Ljava/lang/String;");
+ jstring duid_s = mc_opt_field(env, connect_info_obj, connect_info_class, "duid", "Ljava/lang/String;");
+ jbyteArray psn_account_id_a = mc_opt_field(env, connect_info_obj, connect_info_class, "psnAccountId", "[B");
+ if(psn_token_s && duid_s)
+ {
+ const char *tok = E->GetStringUTFChars(env, psn_token_s, NULL);
+ const char *duid = E->GetStringUTFChars(env, duid_s, NULL);
+ connect_info.holepunch_session = mc_psn_connect(log, tok, duid, connect_info.ps5);
+ E->ReleaseStringUTFChars(env, psn_token_s, tok);
+ E->ReleaseStringUTFChars(env, duid_s, duid);
+ if(!connect_info.holepunch_session)
+ {
+ err = CHIAKI_ERR_HOST_UNREACH;
+ android_chiaki_video_decoder_fini(&session->video_decoder);
+ android_chiaki_audio_decoder_fini(&session->audio_decoder);
+ android_chiaki_audio_output_free(session->audio_output);
+ free(session);
+ session = NULL;
+ goto beach;
+ }
+ if(psn_account_id_a && E->GetArrayLength(env, psn_account_id_a) == CHIAKI_PSN_ACCOUNT_ID_SIZE)
+ {
+ jbyte *acc = E->GetByteArrayElements(env, psn_account_id_a, NULL);
+ memcpy(connect_info.psn_account_id, acc, CHIAKI_PSN_ACCOUNT_ID_SIZE);
+ E->ReleaseByteArrayElements(env, psn_account_id_a, acc, JNI_ABORT);
+ }
+ }
+ }
+
+ err = chiaki_session_init(&session->session, &connect_info, log);"""
+a2 = "\terr = chiaki_session_init(&session->session, &connect_info, log);"
+assert s.count(a2) == 1
+s = s.replace(a2, block, 1)
+if "remote/holepunch.h" not in s:
+ s = s.replace("#include ", "#include \n#include ", 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_nano_path_log.py b/native/mc_nano_path_log.py
new file mode 100644
index 0000000000..4a0fa3506c
--- /dev/null
+++ b/native/mc_nano_path_log.py
@@ -0,0 +1,49 @@
+import sys
+# MoonCast: log which ICE path nano-rs uses (direct on the LAN, server-reflexive, or a relay), every ~10 s of stats.
+p = sys.argv[1] + "/src/webrtc_backend.rs"
+s = open(p).read()
+if "mc path" in s:
+ print("already patched"); sys.exit(0)
+old = ''' StatsReportType::CandidatePair(candidate)
+ if candidate.state.to_string() == "succeeded" =>
+ {
+'''
+new = ''' StatsReportType::CandidatePair(candidate)
+ if candidate.state.to_string() == "succeeded" =>
+ {
+ if mc_log_now {
+ crate::nano_log!("mc path pair local={} remote={} nominated={} rtt={:.1}ms",
+ candidate.local_candidate_id, candidate.remote_candidate_id, candidate.nominated,
+ candidate.current_round_trip_time * 1000.0);
+ }
+'''
+assert s.count(old) == 1
+s = s.replace(old, new)
+old2 = ''' _ => {}
+ }
+ }
+
+ if stats.packets_received == 0 {'''
+new2 = ''' StatsReportType::LocalCandidate(c) | StatsReportType::RemoteCandidate(c) if mc_log_now => {
+ crate::nano_log!("mc path candidate id={} type={:?} addr={}:{} net={:?}",
+ c.id, c.candidate_type, c.ip, c.port, c.network_type);
+ }
+ _ => {}
+ }
+ }
+
+ if stats.packets_received == 0 {'''
+assert s.count(old2) == 1
+s = s.replace(old2, new2)
+old3 = ''' for report in report.reports.values() {
+ match report {
+ StatsReportType::CandidatePair(candidate)'''
+new3 = ''' static MC_STATS_TICK: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
+ let mc_log_now = MC_STATS_TICK.fetch_add(1, Ordering::Relaxed) % 10 == 2;
+ for report in report.reports.values() {
+ match report {
+ StatsReportType::CandidatePair(candidate)'''
+assert s.count(old3) == 1
+s = s.replace(old3, new3)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_port_guessing_patch.py b/native/mc_port_guessing_patch.py
new file mode 100644
index 0000000000..bcfc80107c
--- /dev/null
+++ b/native/mc_port_guessing_patch.py
@@ -0,0 +1,30 @@
+import sys
+p = sys.argv[1] # android/app/src/main/cpp/chiaki-jni.c (already patched by mc_jni_patch.py)
+s = open(p).read()
+if "force_port_guessing(hs, true)" in s:
+ print("already patched"); sys.exit(0)
+
+a = """ ChiakiHolepunchSession hs = chiaki_holepunch_session_init(token, log);
+ if(!hs)
+ {
+ CHIAKI_LOGE(log, "MoonCast PSN: hole-punch session init failed (token?)");
+ return NULL;
+ }"""
+assert s.count(a) == 1
+s = s.replace(a, a + """
+ // Port guessing (chiaki-ng's fix for port-rewriting NATs): when the STUN test sees a router that rewrites our
+ // port, advertise a spread of guessed ports and open many sockets so the console's packets hit one of them.
+ // Some JAL<->home punches got no candidate response at all; this targets that. Defaults: 75 guesses, 250 sockets.
+ chiaki_holepunch_session_force_port_guessing(hs, true);""", 1)
+
+b = """ if(err == CHIAKI_ERR_SUCCESS)
+ err = holepunch_session_create_offer(hs);"""
+assert s.count(b) == 1
+s = s.replace(b, b + """
+ {
+ int32_t inc = 0; bool rnd = false;
+ if(chiaki_holepunch_session_get_stun_allocation(hs, &inc, &rnd))
+ CHIAKI_LOGI(log, "MoonCast PSN: our NAT allocation increment %d, random %d", (int)inc, (int)rnd);
+ }""", 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_quality_patch.py b/native/mc_quality_patch.py
new file mode 100644
index 0000000000..f9e5d124da
--- /dev/null
+++ b/native/mc_quality_patch.py
@@ -0,0 +1,16 @@
+import sys
+p = sys.argv[1] # android/app/src/main/cpp/chiaki-jni.c
+s = open(p).read()
+if "packet_loss_max = 0.05" in s:
+ print("already patched"); sys.exit(0)
+a = "\tconnect_info.video_profile_auto_downgrade = true;"
+assert s.count(a) == 1
+s = s.replace(a, a + """
+ // MoonCast: the Android binding left these zeroed. packet_loss_max 0 made chiaki always report 0% loss to the
+ // console ("Clamping reported packet loss: measured=12.1% reported_max=0.0%"), so the PS5 never lowered its
+ // bitrate on a lossy link. Same values as chiaki-ng desktop: report real loss up to 5%, and request a fresh
+ // keyframe (IDR) when FEC can't repair a frame instead of decoding corrupt frames until the next one.
+ connect_info.packet_loss_max = 0.05;
+ connect_info.enable_idr_on_fec_failure = true;""", 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_stats_patch.py b/native/mc_stats_patch.py
new file mode 100644
index 0000000000..b2f6eb807e
--- /dev/null
+++ b/native/mc_stats_patch.py
@@ -0,0 +1,66 @@
+import os, sys
+# MoonCast: live stream stats for the PS5 Quick menu. Counts shown and lost frames in the Android decoder, keeps the
+# delay the PS5 reports in its connection-quality messages, and exposes them with chiaki's measured bitrate and packet
+# loss as sessionStats(). Run with the chiaki-ng root as the argument.
+root = sys.argv[1]
+
+def patch(rel, pairs, marker):
+ p = os.path.join(root, rel)
+ s = open(p).read()
+ if marker in s:
+ print(rel, "already patched"); return
+ for old, new in pairs:
+ assert s.count(old) == 1, (rel, old[:50])
+ s = s.replace(old, new)
+ open(p, "w").write(s)
+ print(rel, "patched")
+
+patch("lib/include/chiaki/streamconnection.h", [(
+ "\tdouble measured_bitrate;\n} ChiakiStreamConnection;",
+ "\tdouble measured_bitrate;\n\tdouble mc_rtt; // MoonCast: rtt from the latest connection-quality message\n} ChiakiStreamConnection;")],
+ "mc_rtt")
+
+patch("lib/src/streamconnection.c", [(
+ "\t\tstream_connection->measured_bitrate = chiaki_stream_stats_bitrate(",
+ "\t\tstream_connection->mc_rtt = q.rtt;\n\t\tstream_connection->measured_bitrate = chiaki_stream_stats_bitrate(")],
+ "mc_rtt")
+
+patch("android/app/src/main/cpp/video-decoder.h", [(
+ "\tChiakiCodec target_codec;\n} AndroidChiakiVideoDecoder;",
+ "\tChiakiCodec target_codec;\n\tuint64_t mc_frames_shown; // MoonCast stats\n\tuint64_t mc_frames_lost;\n} AndroidChiakiVideoDecoder;")],
+ "mc_frames_shown")
+
+patch("android/app/src/main/cpp/video-decoder.c", [
+ ("\tdecoder->shutdown_output = false;\n\treturn chiaki_mutex_init(",
+ "\tdecoder->shutdown_output = false;\n\tdecoder->mc_frames_shown = 0;\n\tdecoder->mc_frames_lost = 0;\n\treturn chiaki_mutex_init("),
+ ("\t\t\tAMediaCodec_releaseOutputBuffer(decoder->codec, (size_t)status, info.size != 0);\n",
+ "\t\t\tAMediaCodec_releaseOutputBuffer(decoder->codec, (size_t)status, info.size != 0);\n\t\t\tif(info.size != 0)\n\t\t\t\tdecoder->mc_frames_shown++;\n"),
+ ("\tAndroidChiakiVideoDecoder *decoder = user;\n\tchiaki_mutex_lock(&decoder->codec_mutex);\n",
+ "\tAndroidChiakiVideoDecoder *decoder = user;\n\tif(frames_lost > 0)\n\t\tdecoder->mc_frames_lost += frames_lost;\n\tchiaki_mutex_lock(&decoder->codec_mutex);\n"),
+], "mc_frames_shown")
+
+patch("android/app/src/main/cpp/chiaki-jni.c", [(
+ "JNIEXPORT jint JNICALL JNI_FCN(sessionGotoBed)",
+ """/** MoonCast: [measured Mbps, packet loss 0..1, PS5-reported rtt, rtt measured at connect (ms), frames shown, frames lost] */
+JNIEXPORT jdoubleArray JNICALL JNI_FCN(sessionStats)(JNIEnv *env, jobject obj, jlong ptr)
+{
+ AndroidChiakiSession *session = (AndroidChiakiSession *)ptr;
+ jdouble v[6] = { 0, 0, 0, 0, 0, 0 };
+ if(session)
+ {
+ ChiakiStreamConnection *sc = &session->session.stream_connection;
+ v[0] = sc->measured_bitrate;
+ v[1] = sc->congestion_control.packet_loss;
+ v[2] = sc->mc_rtt;
+ v[3] = session->session.rtt_us / 1000.0;
+ v[4] = (jdouble)session->video_decoder.mc_frames_shown;
+ v[5] = (jdouble)session->video_decoder.mc_frames_lost;
+ }
+ jdoubleArray r = E->NewDoubleArray(env, 6);
+ if(r)
+ E->SetDoubleArrayRegion(env, r, 0, 6, v);
+ return r;
+}
+
+JNIEXPORT jint JNICALL JNI_FCN(sessionGotoBed)""")],
+ "JNI_FCN(sessionStats)")
diff --git a/native/mc_surface_lock_patch.py b/native/mc_surface_lock_patch.py
new file mode 100644
index 0000000000..59de538226
--- /dev/null
+++ b/native/mc_surface_lock_patch.py
@@ -0,0 +1,40 @@
+import sys
+# MoonCast (#37): removing the surface froze the PS5 decoder for good. set_surface(NULL) holds codec_mutex and then
+# calls kill_decoder(), which takes the same (non-recursive) mutex again, so the thread deadlocks. It also returned
+# with the mutex still held. The session could then never be freed, so every PS5 game kept one hardware decoder
+# until the app was killed, and PC streams on the stick failed (-5) once they ran out.
+# Fix: release the mutex before kill_decoder() (it locks it itself), and release the old window too.
+# Run with android/app/src/main/cpp/video-decoder.c as the argument.
+p = sys.argv[1]
+s = open(p).read()
+if "MoonCast (#37)" in s:
+ print("already patched"); sys.exit(0)
+old = """ if(!surface)
+ {
+ if(decoder->codec)
+ {
+ kill_decoder(decoder);
+ CHIAKI_LOGI(decoder->log, "Decoder shut down after surface was removed");
+ }
+ return;
+ }"""
+new = """ if(!surface)
+ {
+ // MoonCast (#37): kill_decoder() takes codec_mutex itself and it isn't recursive, so release it first
+ chiaki_mutex_unlock(&decoder->codec_mutex);
+ if(decoder->codec)
+ {
+ kill_decoder(decoder);
+ CHIAKI_LOGI(decoder->log, "Decoder shut down after surface was removed");
+ }
+ if(decoder->window)
+ {
+ ANativeWindow_release(decoder->window);
+ decoder->window = NULL;
+ }
+ return;
+ }"""
+assert s.count(old) == 1, "set_surface(NULL) block not found"
+s = s.replace(old, new, 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/native/mc_wake_timeout_patch.py b/native/mc_wake_timeout_patch.py
new file mode 100644
index 0000000000..b7b688bb1f
--- /dev/null
+++ b/native/mc_wake_timeout_patch.py
@@ -0,0 +1,21 @@
+import sys
+p = sys.argv[1] # lib/src/remote/holepunch.c
+s = open(p).read()
+if "SESSION_START_WAKE_TIMEOUT_SEC" in s:
+ print("already patched"); sys.exit(0)
+d = "#define SESSION_START_TIMEOUT_SEC 30"
+assert s.count(d) == 1
+s = s.replace(d, d + """
+// MoonCast: a console in rest mode only joins the PSN session (MEMBER_CREATED) once it has woken up, which
+// took ~46 s in testing (30 s always failed). Not longer: a PS5 still *entering* rest mode ignores the start request,
+// and waiting on the same request never helps - a fresh attempt does (connect() retries).
+#define SESSION_START_WAKE_TIMEOUT_SEC 60""", 1)
+w = """ int notif_query = NOTIFICATION_TYPE_MEMBER_CREATED
+ | NOTIFICATION_TYPE_CUSTOM_DATA1_UPDATED;
+ while (!finished)
+ {
+ err = wait_for_notification(session, ¬if, notif_query, SESSION_START_TIMEOUT_SEC * 1000);"""
+assert s.count(w) == 1
+s = s.replace(w, w.replace("SESSION_START_TIMEOUT_SEC * 1000", "SESSION_START_WAKE_TIMEOUT_SEC * 1000"), 1)
+open(p, "w").write(s)
+print("patched")
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 0000000000..27f20fed07
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,32 @@
+#!/bin/sh
+# Builds signed release APKs of both apps and checks their signature.
+# Usage: MOONCAST_KEYSTORE=/path/to/keystore.properties scripts/release.sh
+# A beta: VERSION_NAME=1.0-beta1 VERSION_CODE=991 scripts/release.sh (the code stays below the release it leads to)
+# (or keystore.properties next to settings.gradle). Writes out/MoonCast-.apk and out/MoonCastTV-.apk.
+set -e
+cd "$(dirname "$0")/.."
+PROPS=${MOONCAST_KEYSTORE:-keystore.properties}
+[ -f "$PROPS" ] || { echo "release.sh: no keystore.properties (set MOONCAST_KEYSTORE)" >&2; exit 1; }
+export MOONCAST_KEYSTORE=$(cd "$(dirname "$PROPS")" && pwd)/$(basename "$PROPS")
+
+# apksigner runs on Java too
+[ -n "$JAVA_HOME" ] && PATH="$JAVA_HOME/bin:$PATH"
+SDK=${ANDROID_HOME:-$(sed -n 's/^sdk\.dir=//p' local.properties 2>/dev/null)}
+APKSIGNER=$(ls "$SDK"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1)
+[ -n "$APKSIGNER" ] || { echo "release.sh: apksigner not found under $SDK/build-tools" >&2; exit 1; }
+
+if [ -n "$VERSION_NAME" ]; then
+ [ -n "$VERSION_CODE" ] || { echo "release.sh: VERSION_NAME needs a VERSION_CODE" >&2; exit 1; }
+ ./gradlew --no-daemon -PmcVersionName="$VERSION_NAME" -PmcVersionCode="$VERSION_CODE" assembleNonRootHandheldRelease assembleNonRootTvRelease
+ VERSION=$VERSION_NAME
+else
+ ./gradlew --no-daemon assembleNonRootHandheldRelease assembleNonRootTvRelease
+ VERSION=$(sed -n 's/.*?: "\([0-9][^"]*\)")$/\1/p' app/build.gradle | head -1)
+fi
+mkdir -p out
+cp app/build/outputs/apk/nonRootHandheld/release/app-nonRoot-handheld-release.apk "out/MoonCast-$VERSION.apk"
+cp app/build/outputs/apk/nonRootTv/release/app-nonRoot-tv-release.apk "out/MoonCastTV-$VERSION.apk"
+for a in "out/MoonCast-$VERSION.apk" "out/MoonCastTV-$VERSION.apk"; do
+ echo "== $a"
+ "$APKSIGNER" verify --print-certs "$a" | grep -E "Signer #1 certificate (DN|SHA-256)"
+done
diff --git a/third-party/chiaki-ng b/third-party/chiaki-ng
new file mode 160000
index 0000000000..c5fa7fad63
--- /dev/null
+++ b/third-party/chiaki-ng
@@ -0,0 +1 @@
+Subproject commit c5fa7fad63d56e08fc503c3b746bec4824dfaabc