Add Quest 3/3S compatibility to the standard APK (#642)

This commit is contained in:
Jagger15 authored and GitHub committed 2026-10-12 17:13:07 -04:00
1 parent 5e38e79c96
commit 2503d69293
25 files changed
+659 -12

No files matched your search

+2 -1
View File
@@ -186,7 +186,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -464,6 +464,7 @@ jobs:
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
python3 tools/release/runtime_inputs.py record
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
+10 -3
View File
@@ -3600,6 +3600,13 @@ static bool wait_snapshot(std::shared_ptr<FakeController> fake,
}
}
static int forward_ioctl(int fd, ioctl_request_t op, void *argp) {
using ioctl_fn = int (*)(int, ioctl_request_t, ...);
// Preserve later preload hooks, including GPU allocation adapters.
static const auto next = reinterpret_cast<ioctl_fn>(dlsym(RTLD_NEXT, "ioctl"));
return next ? next(fd, op, argp) : static_cast<int>(syscall(SYS_ioctl, fd, op, argp));
}
EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
va_list va;
void *argp;
@@ -3611,7 +3618,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
// The lock must not be held across the passthrough: binder's transport is a
// blocking ioctl(BINDER_WRITE_READ), so a parked binder pool thread would own
// controller_mutex() for as long as it waits and deadlock every other ioctl.
if (!watched(fd)) return syscall(SYS_ioctl, fd, op, argp);
if (!watched(fd)) return forward_ioctl(fd, op, argp);
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto maker = uinput_map().find(fd);
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
@@ -3629,7 +3636,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
auto controller = controller_map().find(fd);
if (controller == controller_map().end()) {
guard.unlock();
return syscall(SYS_ioctl, fd, op, argp);
return forward_ioctl(fd, op, argp);
}
int type = (op >> 8 & 0xFF);
@@ -3825,7 +3832,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
} else {
Logger::log("Unhandled evdev ioctl, type %d number %d\n", type, number);
guard.unlock();
return syscall(SYS_ioctl, fd, op, argp);
return forward_ioctl(fd, op, argp);
}
}
@@ -22,6 +22,7 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
GpuClockPin.clearLeftover(this)
@@ -50,7 +50,8 @@ public final class TurnipDriver {
/** Adreno 8xx (8 Elite, e.g. a Fold 8's A830). */
private static final String DRIVER_A8XX = "turnip-sdk36";
/** The two the apk carries; never removable, unpacked on demand. */
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX));
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(
DRIVER_A7XX, DRIVER_A8XX, com.droiddeck.launcher.session.QuestExperimental.DRIVER));
/** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */
private static final String OVERRIDE_FILE = "Download/droiddeck-driver";
/** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */
@@ -33,7 +33,7 @@ import java.util.concurrent.atomic.AtomicInteger
*/
object BwrapSpawner {
private const val TAG = "BwrapSpawner"
const val SOCKET = "com.droiddeck.launcher.bwrap"
fun socket(context: Context): String = "${context.packageName}.bwrap"
private val seq = AtomicInteger()
@Volatile private var server: LocalServerSocket? = null
@@ -42,10 +42,11 @@ object BwrapSpawner {
fun start(context: Context) {
if (server != null) return
val app = context.applicationContext
val name = socket(app)
val socket = try {
LocalServerSocket(SOCKET)
LocalServerSocket(name)
} catch (e: IOException) {
Log.e(TAG, "cannot listen on $SOCKET", e)
Log.e(TAG, "cannot listen on $name", e)
return
}
server = socket
@@ -51,6 +51,7 @@ object GuestCommand {
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
cmd += argv
@@ -24,6 +24,12 @@ object CrashHandler {
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
try {
Log.e(TAG, "uncaught on ${thread.name}", error)
if (QuestExperimental.isSupported()) runCatching {
File(context.filesDir, "quest-last-crash.txt").writeText(
"${java.time.Instant.now()} thread=${thread.name}\n" +
StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
)
}
val dir = SessionPaths.current()
if (dir != null && dir.isDirectory) {
val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
@@ -0,0 +1,60 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Build
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import java.io.File
/** Device-detected defaults and launch evidence for experimental Quest compatibility. */
object QuestExperimental {
private const val TAG = "QuestExperimental"
const val DRIVER = "turnip25.1.0-quest1"
// Quest 2 has an A6xx GPU and cannot use this bundled A7xx driver.
internal fun supported(model: String, device: String, manufacturer: String): Boolean =
model.equals("Quest 3", ignoreCase = true) || model.equals("Quest 3S", ignoreCase = true) ||
((manufacturer.equals("Oculus", ignoreCase = true) || manufacturer.equals("Meta", ignoreCase = true)) &&
(device == "eureka" || device == "panther"))
fun isSupported(): Boolean = supported(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
fun initialize(context: Context) {
if (!isSupported()) return
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
if (!prefs.getBoolean("questDefaultsV1", false)) {
// Auto would download an unwrapped display driver and lose the compatibility path.
// Apply once so deliberate changes made during headset testing remain possible.
prefs.edit()
.putString("gpuDriverMode", SessionPrefs.GPU_DRIVERS_MANUAL)
.putString("androidDriver", DRIVER)
.putString("linuxDriver", "")
// The existing HDR option uses SurfaceControl presentation. This is a
// corruption workaround from upstream issue #245, not a claim of HDR accuracy.
.putBoolean("hdr.steam", true)
.putBoolean("questDefaultsV1", true)
.apply()
}
val report = buildString {
appendLine("DroidDeck Quest Experimental ${BuildConfig.VERSION_NAME}")
appendLine("package=${context.packageName}")
appendLine("manufacturer=${Build.MANUFACTURER} model=${Build.MODEL}")
appendLine("device=${Build.DEVICE} board=${Build.BOARD} hardware=${Build.HARDWARE}")
appendLine("android=${Build.VERSION.RELEASE} sdk=${Build.VERSION.SDK_INT}")
appendLine("abis=${Build.SUPPORTED_ABIS.joinToString()}")
for (name in listOf("gpu_model", "gpu_chipid")) {
val value = runCatching { File("/sys/class/kgsl/kgsl-3d0/$name").readText().trim() }
.getOrDefault("unavailable")
appendLine("$name=$value")
}
appendLine("driverMode=${SessionPrefs.gpuDriverMode(context)}")
appendLine("androidDriver=${SessionPrefs.androidDriver(context)}")
appendLine("steamSurfaceControlWorkaround=${SessionPrefs.hdr(context, "steam")}")
appendLine("GPUOBJ fallback installed in Android driver and ARM64 libblsession")
appendLine("Experimental Quest 3/3S compatibility; 2D panel application")
}
Log.i(TAG, report)
runCatching { File(context.filesDir, "quest-startup.txt").writeText(report) }
.onFailure { Log.w(TAG, "Could not write startup report", it) }
}
}
@@ -585,6 +585,8 @@ class SessionService : Service() {
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean) {
guest.add("/usr/bin/env")
guest.add("-i")
// PRoot executes guest[0] directly; assignments belong after env -i.
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
@@ -0,0 +1,73 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.ContextWrapper
import com.droiddeck.launcher.runtime.BwrapSpawner
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowBuild
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class QuestExperimentalTest {
private val context: Context get() = RuntimeEnvironment.getApplication()
@Before fun reset() {
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
ShadowBuild.setModel("Phone")
ShadowBuild.setDevice("phone")
}
@Test fun otherDevicesAndQuest2KeepTheirSettings() {
for (model in listOf("Phone", "Quest 2")) {
ShadowBuild.setModel(model)
QuestExperimental.initialize(context)
assertEquals(SessionPrefs.GPU_DRIVERS_AUTO, SessionPrefs.gpuDriverMode(context))
assertEquals("", SessionPrefs.androidDriver(context))
assertFalse(SessionPrefs.hdr(context, "steam"))
}
assertFalse(QuestExperimental.supported("Quest 2", "hollywood", "Oculus"))
}
@Test fun questDefaultsSelectBothCompatibilityPathsAndAreAppliedOnlyOnce() {
ShadowBuild.setModel("Quest 3")
val standard = object : ContextWrapper(context) {
override fun getPackageName() = "com.droiddeck.launcher"
}
QuestExperimental.initialize(standard)
assertEquals(SessionPrefs.GPU_DRIVERS_MANUAL, SessionPrefs.gpuDriverMode(context))
assertEquals(QuestExperimental.DRIVER, SessionPrefs.androidDriver(context))
assertEquals("", SessionPrefs.linuxDriver(context))
assertTrue(SessionPrefs.hdr(context, "steam"))
assertTrue(context.filesDir.resolve("quest-startup.txt").readText().contains("model=Quest 3"))
SessionPrefs.setAndroidDriver(context, "chosen-driver")
SessionPrefs.setHdr(context, "steam", false)
QuestExperimental.initialize(context)
assertEquals("chosen-driver", SessionPrefs.androidDriver(context))
assertFalse(SessionPrefs.hdr(context, "steam"))
}
@Test fun quest3sIsRecognizedByModelOrDevice() {
assertTrue(QuestExperimental.supported("Quest 3S", "unknown", "Oculus"))
assertTrue(QuestExperimental.supported("unknown", "panther", "Oculus"))
assertTrue(QuestExperimental.supported("unknown", "eureka", "Meta"))
assertFalse(QuestExperimental.supported("Pixel 7", "panther", "Google"))
}
@Test fun installedPackagesUseSeparateSandboxSockets() {
val packages = listOf("com.droiddeck.launcher", "com.tencent.ig",
"com.antutu.benchmark.full", "com.ludashi.benchmark")
val sockets = packages.map { name ->
val variant = object : ContextWrapper(context) {
override fun getPackageName() = name
}
BwrapSpawner.socket(variant).also { assertEquals("$name.bwrap", it) }
}
assertEquals(packages.size, sockets.toSet().size)
}
}
+40
View File
@@ -0,0 +1,40 @@
# Experimental Quest 3/3S support
Quest 3/3S runs DroidDeck as a 2D panel. This does not add immersive VR or
OpenXR support. Quest 2 is outside the bundled Adreno 7xx driver path.
Install the standard DroidDeck APK on Quest 3/3S. Device detection on the
first launch selects manual drivers, the bundled
`turnip25.1.0-quest1` Android driver and the default Linux driver. It enables
`hdr.steam` to use the existing SurfaceControl presentation workaround from
[the Quest report](https://github.com/Droid-Deck/DroidDeck/issues/245).
These defaults apply once; subsequent user changes are retained. Colour
accuracy and headset performance still need testing.
Both the Android driver wrapper and the Linux session preload use
`tools/quest/kgsl_compat.c`. Successful legacy allocation/free calls are
unchanged. Only `EACCES`/`EPERM` failures retry through `GPUOBJ_ALLOC`,
`GPUOBJ_INFO` and `GPUOBJ_FREE`. The Android wrapper depends on the original
bundled Turnip binary. Unhandled controller ioctls forward to the next
preload hook rather than skipping it with a raw syscall.
`tools/build_local.sh` and CI build the wrapper from source using the pinned
Android NDK; the normal preload builds include the shared Linux adapter.
Runtime input verification checks the resulting driver archive and invalidates
stale prepared assets after compatibility sources change. To rebuild only the
Android driver archive:
```sh
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/27.3.13750724"
```
Quest compatibility is included in the standard APK; no separate Quest
package is needed. Other devices retain their existing defaults. The remaining
performance-name APK variants continue to use separate app data and bwrap sockets.
Quest 3 testers have successfully launched games.
On a headset, check the Steam login panel, sign-in, game installation and
then an owned game's rendering/input. Capture the device/OS, session logs,
`files/quest-startup.txt` and any `files/quest-last-crash.txt` failure.
Host allocation and preload-chain tests do not establish headset operation.
+1
View File
@@ -320,6 +320,7 @@ else
fi
cd "${repo_root}"
python3 tools/quest/build-driver.py "${NDK}"
bash tools/comet/build-local.sh
python3 tools/release/runtime_inputs.py record
./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}" ${gradle_rust_arg}
@@ -20,7 +20,7 @@ import socket
import struct
import sys
SOCKET = "\0com.droiddeck.launcher.bwrap"
SOCKET = "\0" + os.environ.get("DROIDDECK_BWRAP_SOCKET", "com.droiddeck.launcher.bwrap")
VERSION = "bubblewrap 0.11.0 (droiddeck)"
# Options and how many arguments each takes; bwrap(1).
+2 -1
View File
@@ -421,6 +421,7 @@ static int is_link_query(unsigned long request) {
}
void bl_kgsl_chip_id_fixup(unsigned long request, void *arg, int rc); /* kgslid.c */
#include "../../quest/kgsl_compat.h"
int bl_kgsl_poll(int (*real)(int, unsigned long, void *), int fd, unsigned long request,
void *arg, int *rc) __attribute__((visibility("hidden")));
int bl_ntsync_ioctl(int fd, unsigned long request, void *arg, int *rc) __attribute__((visibility("hidden")));
@@ -440,7 +441,7 @@ int ioctl(int fd, unsigned long request, ...) {
if (bl_ntsync_ioctl(fd, request, arg, &rc)) return rc;
if (bl_drm_gem_close(fd, request, arg, &rc)) return rc;
if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl");
if (!bl_kgsl_poll(real, fd, request, arg, &rc)) rc = real(fd, request, arg);
if (!bl_kgsl_poll(real, fd, request, arg, &rc)) rc = quest_kgsl_ioctl(real, fd, request, arg);
bl_kgsl_chip_id_fixup(request, arg, rc);
if (arg == NULL || !is_link_query(request)) return rc;
int saved = errno;
+3
View File
@@ -0,0 +1,3 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* Shared with the Android driver wrapper so the two translation paths cannot drift. */
#include "../../quest/kgsl_compat.c"
+26
View File
@@ -0,0 +1,26 @@
/* SPDX-License-Identifier: GPL-3.0-or-later
* Loaded as the AdrenoTools driver; DT_NEEDED points to the original Turnip.
* Vulkan/HAL symbols are resolved from that dependency. Its ioctl calls resolve here.
*/
#define _GNU_SOURCE
#include "kgsl_compat.h"
#include <dlfcn.h>
#include <pthread.h>
#include <stdarg.h>
#include <sys/syscall.h>
#include <unistd.h>
static int (*next_ioctl)(int, int, ...);
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void resolve_ioctl(void) { next_ioctl = dlsym(RTLD_NEXT, "ioctl"); }
static int forward(int fd, unsigned long request, void *arg) {
pthread_once(&once, resolve_ioctl);
return next_ioctl ? next_ioctl(fd, (int)request, arg) : (int)syscall(SYS_ioctl, fd, request, arg);
}
__attribute__((visibility("default"))) int ioctl(int fd, int request, ...) {
va_list ap;
va_start(ap, request);
void *arg = va_arg(ap, void *);
va_end(ap);
return quest_kgsl_ioctl(forward, fd, (unsigned int)request, arg);
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Build the Quest allocation adapter around the unchanged bundled Android Turnip."""
import io
import json
from pathlib import Path
import subprocess
import sys
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[2]
OUTPUT = ROOT / 'app/build/prepared-assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst'
def build(ndk, output=OUTPUT):
hosts = list((Path(ndk) / 'toolchains/llvm/prebuilt').glob('*'))
if len(hosts) != 1:
raise ValueError('Expected one NDK host toolchain')
llvm = hosts[0] / 'bin'
original = ROOT / 'app/src/main/assets/graphics_driver/adrenotools-turnip25.1.0.tzst'
data = subprocess.run(['zstd', '-dc', str(original)], capture_output=True, check=True).stdout
with tempfile.TemporaryDirectory(prefix='quest-driver-') as folder:
work = Path(folder)
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
# Only read the known driver entry; no archive paths are extracted.
driver = archive.extractfile('vulkan.ad07xx.so').read()
(work / 'vulkan.ad07xx.so').write_bytes(driver)
wrapper = work / 'libquest_turnip.so'
subprocess.run([
str(llvm / 'aarch64-linux-android26-clang'), '-shared', '-fPIC', '-O2',
'-Wall', '-Wextra', '-Werror', '-fvisibility=hidden',
'-Wl,-z,max-page-size=16384', '-Wl,-soname,libquest_turnip.so',
'-Wl,--no-as-needed', str(work / 'vulkan.ad07xx.so'), '-Wl,--as-needed',
str(ROOT / 'tools/quest/android_wrapper.c'), str(ROOT / 'tools/quest/kgsl_compat.c'),
'-ldl', '-llog', '-o', str(wrapper),
], check=True)
subprocess.run([str(llvm / 'llvm-strip'), '--strip-unneeded', str(wrapper)], check=True)
metadata = json.dumps({
'libraryName': 'libquest_turnip.so',
'name': 'Turnip Adreno 7xx - Quest GPUOBJ experimental',
'description': 'Bundled A7xx driver with the experimental Quest 3/3S allocation adapter.',
'driverVersion': 'Bundled Mesa + Quest GPUOBJ v1',
}, indent=2).encode() + b'\n'
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode='w', format=tarfile.USTAR_FORMAT) as archive:
for name, content in [('meta.json', metadata), ('libquest_turnip.so', wrapper.read_bytes()),
('vulkan.ad07xx.so', driver)]:
entry = tarfile.TarInfo(name)
entry.size = len(content)
entry.mode = 0o644
archive.addfile(entry, io.BytesIO(content))
compressed = subprocess.run(['zstd', '-q', '-19', '-c'], input=stream.getvalue(),
capture_output=True, check=True).stdout
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(compressed)
print(output)
if __name__ == '__main__':
build(sys.argv[1])
+78
View File
@@ -0,0 +1,78 @@
/* SPDX-License-Identifier: GPL-3.0-or-later
* Quest experimental build, 2026-10-11.
* Horizon permits GPUOBJ allocation, but rejects the legacy GPUMEM alloc/free calls.
* Retry only those two calls after EACCES/EPERM. No change to device permissions.
*/
#include "kgsl_compat.h"
#include <errno.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#ifdef __ANDROID__
#include <android/log.h>
#endif
#if __SIZEOF_POINTER__ == 8
_Static_assert(sizeof(struct kgsl_gpumem_alloc_id) == 48, "KGSL alloc ABI");
_Static_assert(sizeof(struct kgsl_gpuobj_alloc) == 48, "GPUOBJ alloc ABI");
_Static_assert(sizeof(struct kgsl_gpuobj_free) == 32, "GPUOBJ free ABI");
_Static_assert(sizeof(struct kgsl_gpuobj_info) == 48, "GPUOBJ info ABI");
#endif
static void report(const char *operation, int error) {
static unsigned successes, failures;
unsigned *counter = error ? &failures : &successes;
/* Log the first success and at most eight failures per process. */
if (__atomic_fetch_add(counter, 1, __ATOMIC_RELAXED) >= (error ? 8u : 1u)) return;
int saved = errno;
#ifdef __ANDROID__
__android_log_print(error ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO, "QuestKGSL",
"%s: GPUOBJ compatibility path, errno=%d", operation, error);
#else
fprintf(stderr, "[QuestKGSL] %s: GPUOBJ compatibility path, errno=%d\n", operation, error);
#endif
errno = saved;
}
int quest_kgsl_ioctl(quest_ioctl_fn real, int fd, unsigned long request, void *arg) {
int rc = real(fd, request, arg);
if (rc != -1 || (errno != EACCES && errno != EPERM) || arg == NULL) return rc;
if (request == IOCTL_KGSL_GPUMEM_ALLOC_ID) {
struct kgsl_gpumem_alloc_id *old = arg;
struct kgsl_gpuobj_alloc alloc = {.size = old->size, .flags = old->flags};
rc = real(fd, IOCTL_KGSL_GPUOBJ_ALLOC, &alloc);
if (rc != 0) { report("alloc failed", errno); return rc; }
struct kgsl_gpuobj_info info = {.id = alloc.id};
rc = real(fd, IOCTL_KGSL_GPUOBJ_INFO, &info);
int error = errno;
if (rc == 0 && (info.size > SIZE_MAX || alloc.mmapsize > SIZE_MAX ||
info.gpuaddr > ULONG_MAX || info.flags > UINT_MAX)) {
rc = -1;
error = EOVERFLOW;
}
if (rc != 0) {
/* The caller never receives this allocation, so clean it up on an INFO failure. */
struct kgsl_gpuobj_free release = {.id = alloc.id};
if (real(fd, IOCTL_KGSL_GPUOBJ_FREE, &release) != 0)
report("cleanup failed", errno);
errno = error;
report("info failed", error);
return rc;
}
old->id = alloc.id;
old->flags = (unsigned int)info.flags;
old->size = info.size;
old->mmapsize = alloc.mmapsize;
old->gpuaddr = info.gpuaddr;
report("allocation succeeded", 0);
return 0;
}
if (request == IOCTL_KGSL_GPUMEM_FREE_ID) {
const struct kgsl_gpumem_free_id *old = arg;
struct kgsl_gpuobj_free release = {.id = old->id};
rc = real(fd, IOCTL_KGSL_GPUOBJ_FREE, &release);
if (rc != 0) report("free failed", errno);
}
return rc;
}
+15
View File
@@ -0,0 +1,15 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#ifndef DROIDDECK_QUEST_KGSL_COMPAT_H
#define DROIDDECK_QUEST_KGSL_COMPAT_H
/* Use the vendored Qualcomm UAPI, including its architecture-dependent ioctl sizes. */
#ifndef __user
#define __user
#endif
#include "../../app/src/main/cpp/adrenotools/src/hook/kgsl.h"
typedef int (*quest_ioctl_fn)(int, unsigned long, void *);
__attribute__((visibility("hidden")))
int quest_kgsl_ioctl(quest_ioctl_fn real, int fd, unsigned long request, void *arg);
#endif
+77
View File
@@ -0,0 +1,77 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#include "kgsl_compat.h"
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
static unsigned long calls[8];
static int count, old_error, alloc_error, info_error, free_error;
static unsigned long address;
static int mock(int fd, unsigned long request, void *arg) {
assert(fd == 42 && count < 8);
calls[count++] = request;
if (request == IOCTL_KGSL_GPUMEM_ALLOC_ID || request == IOCTL_KGSL_GPUMEM_FREE_ID) {
if (old_error) { errno = old_error; return -1; }
return 0;
}
if (request == IOCTL_KGSL_GPUOBJ_ALLOC) {
if (alloc_error) { errno = alloc_error; return -1; }
struct kgsl_gpuobj_alloc *p = arg;
assert(p->size == 123 && p->flags == 0x10000 && !p->metadata && !p->metadata_len);
p->id = 77; p->size = 4096; p->mmapsize = 8192;
return 0;
}
if (request == IOCTL_KGSL_GPUOBJ_INFO) {
struct kgsl_gpuobj_info *p = arg;
assert(p->id == 77);
if (info_error) { errno = info_error; return -1; }
p->gpuaddr = address; p->flags = 0x20000; p->size = 4096;
return 0;
}
if (request == IOCTL_KGSL_GPUOBJ_FREE) {
struct kgsl_gpuobj_free *p = arg;
assert(p->id == 77 && !p->flags && !p->priv && !p->type && !p->len);
if (free_error) { errno = free_error; return -1; }
return 0;
}
errno = EACCES;
return -1;
}
static struct kgsl_gpumem_alloc_id reset(void) {
count = old_error = alloc_error = info_error = free_error = 0;
address = 0x100000;
return (struct kgsl_gpumem_alloc_id){.size = 123, .flags = 0x10000};
}
int main(void) {
struct kgsl_gpumem_alloc_id p = reset();
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == 0 && count == 1);
p = reset(); old_error = EACCES;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == 0);
assert(count == 3 && p.id == 77 && p.size == 4096 && p.mmapsize == 8192);
assert(p.flags == 0x20000 && p.gpuaddr == address);
p = reset(); old_error = EPERM; address = 0;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == 0 && p.gpuaddr == 0);
p = reset(); old_error = ENOMEM;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == ENOMEM && count == 1);
p = reset(); old_error = EACCES; alloc_error = ENOMEM;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == ENOMEM && count == 2);
assert(p.id == 0 && p.size == 123);
p = reset(); old_error = EACCES; info_error = EIO;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == EIO);
assert(count == 4 && calls[3] == IOCTL_KGSL_GPUOBJ_FREE && p.id == 0);
p = reset(); old_error = EACCES; info_error = EIO; free_error = ENOSPC;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == EIO && count == 4);
p = reset(); old_error = EACCES;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, NULL) == -1 && count == 1);
p = reset(); old_error = EACCES;
assert(quest_kgsl_ioctl(mock, 42, 0x1234, &p) == -1 && errno == EACCES && count == 1);
struct kgsl_gpumem_free_id release = {.id = 77};
reset(); old_error = EACCES;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_FREE_ID, &release) == 0 && count == 2);
reset(); old_error = EPERM; free_error = EIO;
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_FREE_ID, &release) == -1 && errno == EIO && count == 2);
reset();
assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_FREE_ID, &release) == 0 && count == 1);
puts("PASS: 12 GPUOBJ translation, passthrough, failure and cleanup scenarios");
}
+7 -2
View File
@@ -16,7 +16,9 @@ SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app
'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio',
'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env',
'tools/mangoapp', 'tools/msitools', 'tools/comet', 'tools/build_local.sh',
'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal']
'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal',
'tools/quest', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h',
'app/src/main/assets/graphics_driver/adrenotools-turnip25.1.0.tzst']
def digest(path):
@@ -48,11 +50,14 @@ def outputs(root):
for library in ['libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so']:
result['lib/arm64-v8a/' + library] = 'app/src/main/jniLibs/arm64-v8a/' + library
result['assets/pulseaudio.tzst'] = 'app/build/prepared-assets/pulseaudio.tzst'
result['assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst'] = \
'app/build/prepared-assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst'
return result
def required():
paths = ['assets/linuxfs/' + name for name in ['libfakeinput.so', 'libblsession.so',
paths = ['assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst']
paths += ['assets/linuxfs/' + name for name in ['libfakeinput.so', 'libblsession.so',
'libblfastpath.so', 'libssbs.so', 'usr/local/bin/droiddeck-clipboard',
'usr/local/bin/gamescope', 'usr/local/lib/droiddeck/uruntime']]
for arch in ['x86_64', 'i386']:
+84
View File
@@ -0,0 +1,84 @@
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
SOURCE = Path(__file__).resolve().parents[2] / "app/src/main/cpp/fakeinput_steam.cpp"
PROGRAM = r"""
#include <assert.h>
#include <errno.h>
#include <stdarg.h>
#include <sys/ioctl.h>
#include <termios.h>
#include <unistd.h>
#ifdef BACKEND
int ioctl(int fd, unsigned long request, ...) {
va_list ap;
va_start(ap, request);
int *value = va_arg(ap, int *);
va_end(ap);
assert(fd == -1 && request == 0x12345678 && value);
*value = 0x51;
errno = EACCES;
return -1;
}
#else
int main(int argc, char **argv) {
if (argc > 1) {
int value = 0;
assert(ioctl(-1, 0x12345678, &value) == -1);
assert(value == 0x51 && errno == EACCES);
} else {
int fds[2];
assert(pipe(fds) == 0);
struct termios term;
assert(ioctl(fds[0], TCGETS, &term) == -1 && errno == ENOTTY);
close(fds[0]);
close(fds[1]);
}
return 0;
}
#endif
"""
@unittest.skipUnless(sys.platform == "linux" and shutil.which("gcc") and shutil.which("g++"),
"needs Linux, gcc and g++")
class FakeinputIoctlTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.tmp.cleanup)
cls.path = Path(cls.tmp.name)
source = cls.path / "program.c"
source.write_text(PROGRAM)
subprocess.run(["gcc", "-o", str(cls.path / "program"), str(source)], check=True)
subprocess.run(["gcc", "-shared", "-fPIC", "-DBACKEND", "-o",
str(cls.path / "backend.so"), str(source)], check=True)
subprocess.run(["g++", "-shared", "-fPIC", "-O2", "-Wno-attributes", "-pthread",
"-std=c++17", "-static-libstdc++", "-static-libgcc",
"-Wl,--exclude-libs,ALL", "-o", str(cls.path / "libfakeinput.so"),
str(SOURCE), "-ldl"], check=True)
def run_program(self, backend=False):
preload = str(self.path / "libfakeinput.so")
args = [str(self.path / "program")]
if backend:
preload += ":" + str(self.path / "backend.so")
args.append("backend")
subprocess.run(args, env=dict(os.environ, LD_PRELOAD=preload), check=True, timeout=10)
def test_later_interposer_receives_arguments_and_returns_errno(self):
self.run_program(backend=True)
def test_libc_passthrough_retains_kernel_error(self):
self.run_program()
if __name__ == "__main__":
unittest.main()
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
QUEST = Path(__file__).resolve().parents[1] / 'quest'
@unittest.skipUnless(sys.platform == 'linux' and shutil.which('gcc'), 'needs Linux and gcc')
class QuestKgslTest(unittest.TestCase):
def test_allocation_passthrough_translation_and_cleanup(self):
with tempfile.TemporaryDirectory() as folder:
binary = str(Path(folder) / 'test-kgsl')
subprocess.run(['gcc', '-Wall', '-Wextra', '-Werror', '-O2', '-o', binary,
str(QUEST / 'test_kgsl_compat.c'), str(QUEST / 'kgsl_compat.c')], check=True)
result = subprocess.run([binary], capture_output=True, text=True, check=True, timeout=10)
self.assertIn('PASS: 12 GPUOBJ', result.stdout)
if __name__ == '__main__':
unittest.main()
+14
View File
@@ -55,6 +55,20 @@ class RuntimeInputsTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'sources changed'):
runtime.verify(self.root)
def test_changed_quest_source_rejects_old_outputs(self):
source = self.root / 'tools/quest/kgsl_compat.c'
source.parent.mkdir(parents=True)
source.write_text('old compatibility code')
runtime.record(self.root)
source.write_text('new compatibility code')
with self.assertRaisesRegex(ValueError, 'sources changed'):
runtime.verify(self.root)
def test_missing_quest_driver_cannot_be_attested(self):
(self.root / 'app/build/prepared-assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst').unlink()
with self.assertRaisesRegex(ValueError, 'quest1.tzst'):
runtime.record(self.root)
def test_modified_prebuilt_binary_is_rejected(self):
runtime.record(self.root)
(self.root / 'app/src/main/jniLibs/arm64-v8a/libproot.so').write_bytes(b'old APK binary')
+67
View File
@@ -0,0 +1,67 @@
"""Exercise the literal launch prefix from SessionService without an Android runtime.
This is a focused source/exec regression check, not a Kotlin or PRoot integration
test. PRoot executes the first guest argument directly; KEY=VALUE is meaningful
only after /usr/bin/env. Read the production prefix rather than duplicate it.
"""
import json
from pathlib import Path
import re
import subprocess
import unittest
SERVICE = Path(__file__).resolve().parents[2] / (
"app/src/main/java/com/droiddeck/launcher/session/SessionService.kt")
VARIANTS = Path(__file__).resolve().parents[1] / "release/variants.txt"
PACKAGES = [line.split()[1] for line in VARIANTS.read_text().splitlines()
if line.strip() and not line.startswith("#")]
def guest_prefix(package):
source = SERVICE.read_text()
before_environment = source.split("val guest = ArrayList<String>()", 1)[1].split(
"addClientEnvironment(guest, steamHere)", 1)[0]
environment = source.split("private fun addClientEnvironment(", 1)[1].split(
'guest.add("HOME=/root")', 1)[0]
prefix = []
for line in (before_environment + environment).splitlines():
line = line.strip()
if not line.startswith("guest.add("):
continue
expression = line[len("guest.add("):-1]
match = re.fullmatch(r'("[^"\\]*")(?: \+ (.+))?', expression)
if match is None:
raise AssertionError("Launch prefix changed; update its regression harness: " + line)
value = json.loads(match[1])
if match[2]:
if match[2] != "com.droiddeck.launcher.runtime.BwrapSpawner.socket(this)":
raise AssertionError("Unexpected dynamic launch-prefix argument: " + line)
value += package + ".bwrap"
prefix.append(value)
return prefix
class SessionGuestCommandTest(unittest.TestCase):
def test_env_is_the_executable_for_every_package(self):
for package in PACKAGES:
with self.subTest(package=package):
self.assertEqual(["/usr/bin/env", "-i"], guest_prefix(package)[:2])
@unittest.skipUnless(Path("/usr/bin/env").is_file(), "needs /usr/bin/env")
def test_child_receives_its_package_socket_after_environment_is_cleared(self):
for package in PACKAGES:
with self.subTest(package=package):
# env with no command prints the environment it would pass to the
# session. A misplaced assignment fails at exec, before it runs.
result = subprocess.run(
guest_prefix(package), capture_output=True, text=True, check=True,
timeout=5, env={"DROIDDECK_BWRAP_SOCKET": "wrong.bwrap", "STALE_HOST": "1"})
self.assertEqual(
["DROIDDECK_BWRAP_SOCKET=" + package + ".bwrap"],
result.stdout.splitlines())
if __name__ == "__main__":
unittest.main()