Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 42e8bc3f78 Add experimental kbase PanVK build and wired G52 validation 2026-10-03 17:33:08 -04:00
xXJsonDeruloXx ffb4477514 Document Mali software Geometry Wars gameplay validation 2026-10-03 15:53:02 -04:00
xXJsonDeruloXx 6448352dc8 Record Mali-G52 Gamescope Big Picture QR and input validation 2026-10-03 15:21:08 -04:00
xXJsonDeruloXx e427c087b1 Pin the experimental Gamescope build without publishing a release 2026-10-03 15:14:43 -04:00
xXJsonDeruloXx b0b5f78d5d Upload shared-memory pixels again when client buffers are reused 2026-10-03 15:10:32 -04:00
xXJsonDeruloXx feb1fb1faa Mark completed shared-memory uploads ready without a dma-buf fence 2026-10-03 14:49:21 -04:00
xXJsonDeruloXx dcd2641a90 Keep swapchain textures local instead of exporting scan-out buffers 2026-10-03 14:39:51 -04:00
xXJsonDeruloXx 2ece5b7314 Query optional present features after selecting the Vulkan device 2026-10-03 14:28:54 -04:00
xXJsonDeruloXx 423c5388ee Use a software Gamescope swapchain without scan-out texture exports 2026-10-03 14:24:24 -04:00
xXJsonDeruloXx 5f3112fb4b Add software Steam rendering and optional Gamescope present timing 2026-10-03 14:22:02 -04:00
Kurt Himebauch f3600fa618 Merge pull request #157 from Droid-Deck/game-launcher-shortcuts 2026-10-03 12:04:11 -04:00
xXJsonDeruloXx f982ffcaf7 Add game launcher shortcuts 2026-10-02 23:49:26 -04:00
Kurt Himebauch 255c64551e Merge pull request #128 from magarto/upscaler-setting
Expose the compositor's upscaler (FSR 1 / GSR / NIS) as a session setting
2026-10-02 23:29:01 -04:00
Kurt Himebauch 746f28c1b2 Merge branch 'main' into upscaler-setting 2026-10-02 23:25:35 -04:00
Kurt Himebauch 2b481ba250 Merge pull request #155 from Droid-Deck/fix/steam-idle-wake
Fix Steam idle sleep and Android session wake
2026-10-02 21:44:13 -04:00
xXJsonDeruloXx 49622623de Merge remote-tracking branch 'origin/main' into fix/steam-idle-wake 2026-10-02 21:43:52 -04:00
Kurt Himebauch 69f99ade42 Merge pull request #146 from Droid-Deck/fix/deck-controller-discovery 2026-10-02 21:34:08 -04:00
xXJsonDeruloXx c236e9711f Restore Steam after idle sleep through session pause and login1 wake 2026-10-02 21:32:12 -04:00
Kurt Himebauch 4c2efb6688 Merge branch 'main' into fix/deck-controller-discovery 2026-10-02 20:51:17 -04:00
xXJsonDeruloXx 7eb2d1cf1a Trim Deck discovery to mount-ID compatibility fix 2026-10-02 20:35:28 -04:00
xXJsonDeruloXx b4ff83cf9f Recover Deck discovery mount IDs on older kernels 2026-10-02 19:08:01 -04:00
xXJsonDeruloXx 85a07a556c Preserve native HID discovery and trace Deck enumeration 2026-10-02 12:00:12 -04:00
xXJsonDeruloXx 8dfc48d58b Fix Deck controller discovery across sysfs layouts 2026-10-02 10:45:25 -04:00
Magarto 82ebdaa0b5 Merge branch 'main' into upscaler-setting 2026-10-02 15:38:57 +02:00
Magarto cbd4ce6636 Merge branch 'main' into upscaler-setting 2026-10-01 22:02:07 +02:00
magartus d4130181e7 Add the upscaler to the session drawer, applied live
The drawer's Display page gets the same Upscaler and Upscaler sharpness
choices as the session settings. A pick is saved and handed straight to
the compositor, so it takes effect on the next frame without restarting
the session.
2026-10-01 21:21:52 +02:00
Magarto 4e90e03536 Merge branch 'main' into upscaler-setting 2026-10-01 19:10:55 +02:00
Magarto 454193da9b Merge branch 'main' into upscaler-setting 2026-10-01 19:06:00 +02:00
magartus 38d658abf5 Expose the compositor's upscaler as a session setting
The Wayland compositor already carries the X11 renderer's spatial
upscalers (effects_chain.c: FSR 1 EASU+RCAS, Snapdragon GSR and its
quality variant, NIS, RCAS sharpen) behind
WaylandCompositor.nativeSetUpscaler / nativeSetUpscaleSharpness, but
nothing calls them since the Winlator-era drawer was removed, so the
scene is always enlarged to the panel with a plain linear blit.

Add "Upscaler" and "Upscaler sharpness" to the session page's Display
group (off by default, 75%), stored in SessionPrefs and applied next to
nativeSetScaleMode when the session's surface is set up. The choice is
also listed in device.txt.
2026-10-01 18:38:02 +02:00
39 changed files with 1627 additions and 57 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Patch context includes the indentation and blank lines of the source being patched.
tools/gamescope/patches/*.patch -whitespace
tools/panvk/patches/*.patch -whitespace
+8 -1
View File
@@ -18,6 +18,7 @@ on:
workflow_dispatch:
permissions:
actions: read
contents: read
pull-requests: write
@@ -190,7 +191,13 @@ jobs:
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
if [ -n "${GAMESCOPE_ARTIFACT_RUN:-}" ]; then
gh run download "${GAMESCOPE_ARTIFACT_RUN}" -R "${GITHUB_REPOSITORY}" -n gamescope-patched -D gamescope-artifact
mv gamescope-artifact/gamescope.tzst gamescope.tzst
rm -r gamescope-artifact
else
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
fi
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
+10
View File
@@ -61,6 +61,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="droiddeck" android:host="game" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CREATE_SHORTCUT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
+74
View File
@@ -1717,6 +1717,80 @@ __attribute__((visibility("hidden"))) static std::string deck_listing_for(const
return std::string(listing) + "/" + names[which];
}
#ifdef __GLIBC__
// sd-device's path chase now requires a mount ID, which statx only supplies from Linux 5.8.
// Older handheld kernels still publish the real ID in fdinfo. Scope this compatibility path to
// libudev/libsystemd in the Deck-reading Steam client, and never invent a unique mount ID.
__attribute__((visibility("hidden"))) static bool deck_device_library(void *caller) {
Dl_info info;
if (!dladdr(caller, &info) || !info.dli_fname) return false;
const char *name = strrchr(info.dli_fname, '/');
name = name ? name + 1 : info.dli_fname;
return !strncmp(name, "libudev.so", 10) || !strncmp(name, "libsystemd.so", 13);
}
__attribute__((visibility("hidden"))) static bool deck_mount_id_from_fdinfo(
int dirfd, const char *path, int flags, struct statx *buf) {
static auto real_openat = reinterpret_cast<decltype(&::openat)>(dlsym(RTLD_NEXT, "openat"));
static auto real_fstat = reinterpret_cast<decltype(&::fstat)>(dlsym(RTLD_NEXT, "fstat"));
int target;
if (!path || !*path) {
if (!(flags & AT_EMPTY_PATH)) return false;
target = dirfd == AT_FDCWD ? real_openat(AT_FDCWD, ".", O_PATH | O_CLOEXEC) :
syscall(SYS_fcntl, dirfd, F_DUPFD_CLOEXEC, 0);
} else {
target = real_openat(dirfd, path, O_PATH | O_CLOEXEC |
((flags & AT_SYMLINK_NOFOLLOW) ? O_NOFOLLOW : 0));
}
if (target < 0) return false;
struct stat st;
// Pin the inode and check it still matches the successful statx before adding information.
bool same = real_fstat(target, &st) == 0 && (buf->stx_mask & STATX_INO) &&
st.st_ino == buf->stx_ino && major(st.st_dev) == buf->stx_dev_major &&
minor(st.st_dev) == buf->stx_dev_minor;
char name[64];
snprintf(name, sizeof(name), "/proc/self/fdinfo/%d", target);
int info = same ? real_openat(AT_FDCWD, name, O_RDONLY | O_CLOEXEC) : -1;
char value[512];
ssize_t length = info >= 0 ? syscall(SYS_read, info, value, sizeof(value) - 1) : -1;
if (info >= 0) syscall(SYS_close, info);
syscall(SYS_close, target);
if (length < 0) return false;
value[length] = '\0';
for (const char *line = value; line && *line;) {
unsigned long long id;
if (sscanf(line, "mnt_id: %llu", &id) == 1 && id > 0) {
buf->stx_mnt_id = id;
buf->stx_mask |= STATX_MNT_ID;
return true;
}
const char *next = strchr(line, '\n');
line = next ? next + 1 : nullptr;
}
return false;
}
EXPORT int statx(int dirfd, const char *path, int flags, unsigned mask, struct statx *buf) {
static auto real = reinterpret_cast<decltype(&::statx)>(dlsym(RTLD_NEXT, "statx"));
int result = real(dirfd, path, flags, mask, buf);
int saved_errno = errno;
constexpr unsigned unique_mount_id = 0x4000; // STATX_MNT_ID_UNIQUE, newer than build headers.
if (result == 0 && (mask & STATX_MNT_ID) &&
!(buf->stx_mask & (STATX_MNT_ID | unique_mount_id)) && fake_deck_enabled() &&
process_is_steam_client() && deck_device_library(__builtin_return_address(0))) {
bool recovered = deck_mount_id_from_fdinfo(dirfd, path, flags, buf);
static std::atomic<bool> noted_success{false}, noted_failure{false};
if (!(recovered ? noted_success : noted_failure).exchange(true))
Logger::log("deck discovery: statx mount id %s for %s (returned mask=0x%x id=%llu)\n",
recovered ? "recovered from fdinfo" : "unavailable in fdinfo",
path && *path ? path : "(fd)", buf->stx_mask,
static_cast<unsigned long long>(buf->stx_mnt_id));
}
errno = saved_errno;
return result;
}
#endif
EXPORT DIR *opendir(const char *name) {
static auto real = reinterpret_cast<DIR *(*)(const char *)>(dlsym(RTLD_NEXT, "opendir"));
DIR *dir = real(name);
@@ -3478,7 +3478,8 @@ int banner_wayland_run(void) {
/* libdecor binds wl_output at 4; a lower version is a protocol error for the client. */
wl_global_create(display, &wl_output_interface, 4, NULL, bind_output);
wl_global_create(display, &xdg_wm_base_interface, 1, NULL, bind_xdg_wm_base);
wl_global_create(display, &zwp_linux_dmabuf_v1_interface, 4, NULL, bind_dmabuf);
if (vkp_dmabuf_supported())
wl_global_create(display, &zwp_linux_dmabuf_v1_interface, 4, NULL, bind_dmabuf);
/* gamescope's Wayland backend refuses a seat older than 8. */
wl_global_create(display, &wl_seat_interface, 9, NULL, bind_seat);
wl_global_create(display, &banner_desktop_v1_interface, 1, NULL, bind_desktop);
+1 -1
View File
@@ -9,7 +9,7 @@
#define VK_USE_PLATFORM_ANDROID_KHR
#include <vulkan/vulkan.h>
#define VK_GLOBAL_FUNCS(X) X(CreateInstance) X(EnumerateInstanceExtensionProperties)
#define VK_GLOBAL_FUNCS(X) X(CreateInstance) X(EnumerateInstanceExtensionProperties) X(EnumerateInstanceVersion)
#define VK_INSTANCE_FUNCS(X) \
X(EnumeratePhysicalDevices) X(GetPhysicalDeviceProperties) \
+24 -13
View File
@@ -40,6 +40,7 @@ struct vkp_image {
static ANativeWindow *g_window; /* the window frames go to; compositor thread only */
static char *g_driver_path, *g_library_name, *g_native_lib_dir;
static int g_dev_state; /* 0 = not yet, 1 = ok, -1 = failed, -2 = lost (VK_ERROR_DEVICE_LOST) */
static int g_dmabuf_import;
static VkInstance g_inst;
static VkPhysicalDevice g_pd;
static VkDevice g_dev;
@@ -411,11 +412,13 @@ static int dev_init(void) {
"generation %s)", g_colorspace_ext ? "enables" : "has no",
g_colorspace_ext ? "is possible where the screen surface offers one" : "is not possible: tone-mapped instead");
}
/* 1.3 like the X11 renderer's instance: the frame-generation probe (framegen_engine.cpp)
* queries VkPhysicalDeviceVulkan12Features, and a 1.1 instance may be answered as 1.1. */
/* Prefer 1.3 for the frame-generation feature probe, but respect older system loaders. */
uint32_t loader_api = VK_API_VERSION_1_0;
if (g_vk.EnumerateInstanceVersion)
g_vk.EnumerateInstanceVersion(&loader_api);
VkApplicationInfo app = {.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
.pApplicationName = "banner-wayland-present",
.apiVersion = VK_API_VERSION_1_3};
.apiVersion = loader_api < VK_API_VERSION_1_3 ? loader_api : VK_API_VERSION_1_3};
VkInstanceCreateInfo ici = {.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
.pApplicationInfo = &app,
.enabledExtensionCount = n_inst_exts,
@@ -450,18 +453,25 @@ static int dev_init(void) {
}
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
/* Verify the dmabuf-import extensions are present, and log any that are missing. */
const char *dev_exts[7] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME, "VK_KHR_external_memory_fd",
"VK_EXT_external_memory_dma_buf", "VK_EXT_image_drm_format_modifier",
"VK_KHR_image_format_list", NULL, NULL};
uint32_t n_dev_exts = 5;
/* Shared-memory clients need only a swapchain. Advertise dma-buf import only when its
* complete extension set is available, rather than failing device creation on older GPUs. */
const char *import_exts[] = {"VK_KHR_external_memory_fd", "VK_EXT_external_memory_dma_buf",
"VK_EXT_image_drm_format_modifier", "VK_KHR_image_format_list"};
const char *dev_exts[7] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME};
uint32_t n_dev_exts = 1;
uint32_t ne = 0;
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL);
VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts));
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts);
for (unsigned i = 0; i < 5; i++)
if (!has_ext(exts, ne, dev_exts[i]))
LOGE("present: driver MISSING %s (dmabuf import will fail)", dev_exts[i]);
g_dmabuf_import = 1;
for (unsigned i = 0; i < sizeof(import_exts) / sizeof(import_exts[0]); i++) {
if (has_ext(exts, ne, import_exts[i]))
dev_exts[n_dev_exts++] = import_exts[i];
else {
g_dmabuf_import = 0;
banner_log("gpu", "driver has no %s; shared-memory clients remain available", import_exts[i]);
}
}
/* HDR sessions only: the HDR10 swapchain (frame generation) can carry the game's metadata. */
int want_hdr_md = 0;
if (banner_color_requested()) {
@@ -538,6 +548,7 @@ static int dev_init(void) {
}
int vkp_ready(void) { return dev_init(); }
int vkp_dmabuf_supported(void) { return dev_init() == 0 && g_dmabuf_import; }
static int swap_init_locked(void);
@@ -899,7 +910,7 @@ static int modifier_importable(VkFormat fmt, uint64_t modifier, VkImageUsageFlag
}
int vkp_dmabuf_modifiers(uint32_t drm_format, uint64_t *out, int max) {
if (max <= 0 || dev_init() != 0 || !g_vk.GetPhysicalDeviceFormatProperties2) return 0;
if (max <= 0 || !vkp_dmabuf_supported() || !g_vk.GetPhysicalDeviceFormatProperties2) return 0;
VkFormat fmt = drm_to_vk(drm_format);
VkDrmFormatModifierPropertiesListEXT list = {
.sType = VK_STRUCTURE_TYPE_DRM_FORMAT_MODIFIER_PROPERTIES_LIST_EXT};
@@ -941,7 +952,7 @@ int vkp_image_is_dmabuf(const struct vkp_image *img) { return img && img->dmabuf
struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t modifier, int w, int h,
uint32_t stride, uint32_t offset, int as_blit_dst) {
if (modifier == MOD_INVALID || w <= 0 || h <= 0) return NULL;
if (dev_init() != 0) return NULL;
if (!vkp_dmabuf_supported()) return NULL;
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
@@ -87,6 +87,8 @@ const char *vkp_gpu_name(void);
// 0 if the renderer can create images (device up), -1 otherwise.
int vkp_ready(void);
/* Whether the selected driver supports the complete dma-buf import extension set. */
int vkp_dmabuf_supported(void);
/* Whether an output window is attached (or requested); without one vkp_render() draws nothing. */
int vkp_has_window(void);
/* 1 once the Vulkan device was lost: nothing is presented any more (the session must restart). */
@@ -304,6 +304,8 @@ class MainActivity : ComponentActivity() {
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
@@ -373,13 +375,75 @@ class MainActivity : ComponentActivity() {
overridePendingTransition(0, 0)
}
private var shortcutPicker by mutableStateOf(false)
private var shortcutLibraryScanning by mutableStateOf(false)
@Volatile private var libraryScanGeneration = 0
private var pendingGameLink: String? = null
private fun readGameIntent(request: Intent) {
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
if (shortcutPicker) {
// The shortcut picker must only offer games from the scan started for this request.
steamGames = emptyList()
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = if (request.action == Intent.ACTION_VIEW)
com.droiddeck.launcher.frontend.GameLaunchLink.parse(request.dataString) else null
if (request.action == Intent.ACTION_VIEW && pendingGameLink == null) {
android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show()
}
}
private fun launchGame(game: Library.SteamGame): Boolean {
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (SessionState.running) {
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
return true
}
if (!SessionService.launchGame(this, game.gameIdString)) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
return startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
}
private fun handleGameLink(games: List<Library.SteamGame>) {
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
val id = pendingGameLink ?: return
val game = games.firstOrNull { it.gameIdString == id }
if (game == null) {
pendingGameLink = null
intent.data = null
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game)) {
pendingGameLink = null
intent.data = null
}
}
private fun chooseGameShortcut(game: Library.SteamGame) {
setResult(RESULT_OK, com.droiddeck.launcher.frontend.GameShortcuts.result(this, game))
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
readGameIntent(intent)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = com.droiddeck.launcher.frontend.LibraryCache.load(this)
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
storeEnabled = SessionPrefs.storeEnabled(this)
@@ -401,6 +465,8 @@ class MainActivity : ComponentActivity() {
FrontEndScreen(
FrontEndState(
installed = installed, ready = ready, available = available?.version,
shortcutPicker = shortcutPicker,
shortcutLibraryScanning = shortcutLibraryScanning,
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
@@ -440,9 +506,12 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
onSteamGame = { g ->
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}"), steamSession = true)
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onCopyGameLink = { g ->
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText(g.name, com.droiddeck.launcher.frontend.GameLaunchLink.uri(g.gameIdString)))
android.widget.Toast.makeText(this, R.string.game_link_copied, android.widget.Toast.LENGTH_SHORT).show()
},
onDesktop = {
startSession(Intent(this, SessionActivity::class.java)
@@ -676,10 +745,13 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
readGameIntent(intent)
refresh()
startSteamAtStartupIfEnabled()
}
private fun startSteamAtStartupIfEnabled() {
if (intent.action != Intent.ACTION_MAIN) return
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
@@ -988,6 +1060,7 @@ class MainActivity : ComponentActivity() {
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
@@ -1042,6 +1115,8 @@ class MainActivity : ComponentActivity() {
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
onUpscaler = { m -> SessionPrefs.setUpscaler(this, m); upscaler = m },
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -1180,6 +1255,8 @@ class MainActivity : ComponentActivity() {
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
@@ -1264,6 +1341,8 @@ class MainActivity : ComponentActivity() {
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// One update with the whole list: the wall places games by their position in it, so a
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
@@ -1280,7 +1359,14 @@ class MainActivity : ComponentActivity() {
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
ui.post { steamGames = all; emulatorList = emus }
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
emulatorList = emus
shortcutLibraryScanning = false
handleGameLink(all)
}
}
// Box art for the games that have none, fetched after the list is up; the list is
// rebuilt once if any was found.
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
@@ -1310,15 +1396,16 @@ class MainActivity : ComponentActivity() {
}
/** Starts a session; with no runtime on a non-Adreno, the same warning Setup gives comes first, before any download. */
private fun startSession(intent: Intent, steamSession: Boolean = false) {
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
refreshPhantomStatus()
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
showPhantomGate = true
return
return false
}
val warn = installed == null && !com.droiddeck.launcher.core.DeviceSupport.adreno()
if (warn && available != null) showNonAdreno = available
else startActivity(intent)
if (warn && available != null) { showNonAdreno = available; return false }
startActivity(intent)
return true
}
private fun refreshPhantomStatus() {
@@ -200,6 +200,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var fillScreen by mutableStateOf(true)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var lossless by mutableStateOf(Lossless.State.NONE)
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
@@ -266,10 +268,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
}
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI)
SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL)
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI)
SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL)
}
if (intent.action == SessionService.ACTION_AGENT_START) {
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
}
@@ -430,6 +434,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
@@ -440,6 +445,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onUpscaler = { m ->
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
WaylandCompositor.nativeSetUpscaler(m)
},
onUpscaleSharpness = { pct ->
SessionPrefs.setUpscaleSharpness(this@SessionActivity, pct); upscaleSharpness = pct
WaylandCompositor.nativeSetUpscaleSharpness(pct)
},
onFrameGenPick = { mode ->
FrameGen.set(this@SessionActivity, mode)
readPrefs()
@@ -659,6 +672,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun readPrefs() {
hudOn = SessionPrefs.hudEnabled(this)
fillScreen = SessionPrefs.forceFullscreen(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
touchMode = SessionPrefs.touchMode(this)
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
@@ -815,6 +830,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Letterbox, never stretch or crop: the output can be a different shape from the panel,
// and a game's picture must keep its proportions with bars, not lose its edges.
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
WaylandCompositor.nativeSetUpscaler(SessionPrefs.upscaler(this))
WaylandCompositor.nativeSetUpscaleSharpness(SessionPrefs.upscaleSharpness(this))
// The session's folder, claimed here because the compositor starts before the service and
// opens its log once. The compositor reads the path from its environment; setting it after
// it has started changes nothing, which is why the service copies the file in at teardown.
@@ -1160,7 +1177,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
val resumeKey = event.keyCode == KeyEvent.KEYCODE_BUTTON_A || event.keyCode == KeyEvent.KEYCODE_BUTTON_START
val resumeKey = event.keyCode == KeyEvent.KEYCODE_BUTTON_A || event.keyCode == KeyEvent.KEYCODE_BUTTON_START ||
event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE || event.keyCode == KeyEvent.KEYCODE_HOME
val resumeKeyId = event.deviceId to event.keyCode
if (fromController && resumeKey && (SessionState.suspended || resumeKeyId in resumeKeysDown)) {
if (event.action == KeyEvent.ACTION_DOWN) {
@@ -150,6 +150,8 @@ object DeviceReport {
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
@@ -0,0 +1,13 @@
package com.droiddeck.launcher.frontend
/** External callers can select a game, never a guest path or arbitrary Steam command. */
object GameLaunchLink {
private val idPattern = Regex("[1-9][0-9]{0,19}")
private val linkPattern = Regex("droiddeck://game/([1-9][0-9]{0,19})")
fun validId(id: String): Boolean = idPattern.matches(id) && id.toULongOrNull() != null
fun parse(link: String?): String? = link?.let { linkPattern.matchEntire(it)?.groupValues?.get(1) }
?.takeIf(::validId)
fun uri(id: String): String { require(validId(id)); return "droiddeck://game/$id" }
fun steamUrl(id: String): String { require(validId(id)); return "steam://rungameid/$id" }
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.frontend
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
object GameShortcuts {
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
Uri.parse(GameLaunchLink.uri(gameId)), context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = game.art?.takeIf { it.isFile }?.let { file -> runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
fun pin(activity: Activity, game: Library.SteamGame) {
val manager = activity.getSystemService(ShortcutManager::class.java)
val requested = runCatching { manager.isRequestPinShortcutSupported &&
manager.requestPinShortcut(info(activity, game), null) }.getOrDefault(false)
if (!requested) Toast.makeText(activity, R.string.game_shortcut_unsupported, Toast.LENGTH_LONG).show()
}
/** Standard shortcut picker result, understood by launchers that import Android shortcuts. */
fun result(activity: Activity, game: Library.SteamGame): Intent =
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
}
@@ -23,7 +23,10 @@ object Library {
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
const val ADDED = "added"
@@ -337,6 +337,10 @@ public final class TurnipDriver {
if (forced.startsWith("a8")) return DRIVER_A8XX;
if (forced.startsWith("a7")) return DRIVER_A7XX;
}
if (!com.droiddeck.launcher.core.DeviceSupport.INSTANCE.adreno()) {
Log.i(TAG, "non-Adreno GPU: using system Vulkan for the compositor");
return null;
}
String model = gpuModel();
Log.i(TAG, "gpu model: " + (model == null ? "unknown" : model));
if (model != null) {
@@ -39,6 +39,7 @@ object SessionFiles {
"usr/local/bin/bannerlator-seed-redists" to "usr/local/bin/bannerlator-seed-redists",
"usr/local/bin/bannerlator-proton-extra" to "usr/local/bin/bannerlator-proton-extra",
"usr/local/bin/bannerlator-netmanager" to "usr/local/bin/bannerlator-netmanager",
"usr/local/bin/bannerlator-login1" to "usr/local/bin/bannerlator-login1",
"usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch",
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
@@ -601,6 +601,26 @@ object SessionPrefs {
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
val upscalerChoices = listOf(
0 to "Off", 4 to "AMD FSR 1", 3 to "Snapdragon GSR", 8 to "Snapdragon GSR (quality)",
7 to "NVIDIA NIS", 6 to "Sharpen only",
)
fun upscaler(context: Context): Int =
prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
fun setUpscaler(context: Context, mode: Int) {
prefs(context).edit().putInt("upscaler", mode).apply()
}
val upscaleSharpnessChoices = listOf(0 to "0%", 25 to "25%", 50 to "50%", 75 to "75%", 100 to "100%")
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
fun setUpscaleSharpness(context: Context, pct: Int) {
prefs(context).edit().putInt("upscaleSharpness", pct.coerceIn(0, 100)).apply()
}
/**
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
@@ -33,6 +33,7 @@ import com.droiddeck.launcher.core.NetworkReport
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.input.FakeInputWriter
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -74,8 +75,16 @@ class SessionService : Service() {
private var activityVisible = true
private var screenOn = true
private var manualPauseRequested = false
/** An explicit Steam sleep request is independent of the background policy. */
private var steamSleepToken: String? = null
private var suspendOperationPending = false
private var suspendAttemptFailed = false
/** One validated game request waiting for this Steam session to reach a usable state. */
private var pendingSteamGameId: String? = null
private var pendingSteamGameGeneration = -1
private val flushSteamGame = object : Runnable {
override fun run() = flushQueuedSteamGame()
}
private var screenReceiverRegistered = false
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -84,6 +93,7 @@ class SessionService : Service() {
Intent.ACTION_SCREEN_ON -> screenOn = true
else -> return
}
if (screenOn) resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
}
@@ -104,6 +114,10 @@ class SessionService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_LAUNCH_GAME -> {
queueSteamGame(intent.getStringExtra(EXTRA_GAME_ID))
return START_NOT_STICKY
}
ACTION_TRACK_AUXILIARY -> {
val pid = intent.getIntExtra(EXTRA_AUXILIARY_PID, -1)
val started = if (pid > 1) readStat(pid)?.second else null
@@ -139,6 +153,7 @@ class SessionService : Service() {
}
ACTION_ACTIVITY_VISIBLE -> {
activityVisible = true
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
@@ -157,11 +172,7 @@ class SessionService : Service() {
return START_NOT_STICKY
}
ACTION_RESUME -> {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendAttemptFailed = false
updateSuspendPolicy()
resumeSession()
return START_NOT_STICKY
}
}
@@ -177,6 +188,7 @@ class SessionService : Service() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
steamSleepToken = null
suspendOperationPending = false
suspendAttemptFailed = false
suspendController = null
@@ -201,6 +213,8 @@ class SessionService : Service() {
// replaces can report its own exit in the gap between the two, and that exit must not
// be taken as this one's.
val gen = ++sessionGen
cancelQueuedSteamGame(clearMailbox = false)
clearSteamGameMailbox()
acquireLocks()
Thread({
// A tree the last session left behind (the app was killed or crashed, so its teardown
@@ -878,6 +892,79 @@ class SessionService : Service() {
return !File("/proc/$pid").exists()
}
private fun queueSteamGame(gameId: String?) {
if (!GameLaunchLink.validId(gameId.orEmpty()) || !SessionState.running ||
SessionState.mode != MODE_STEAM || SessionState.stopRequested) {
Log.w(TAG, "ignored game launch outside a running Steam session")
return
}
val accepted = gameId!!
if (pendingSteamGameId != null) Log.i(TAG, "replacing a queued game launch with $accepted")
pendingSteamGameId = accepted
pendingSteamGameGeneration = sessionGen
if (SessionState.suspended) resumeSession()
mainHandler.removeCallbacks(flushSteamGame)
mainHandler.post(flushSteamGame)
}
private fun flushQueuedSteamGame() {
val gameId = pendingSteamGameId ?: return
val gen = pendingSteamGameGeneration
if (gen != sessionGen || !SessionState.running || SessionState.stopRequested ||
SessionState.mode != MODE_STEAM || !GameLaunchLink.validId(gameId)) {
cancelQueuedSteamGame()
return
}
if (SessionState.suspended) {
resumeSession()
mainHandler.postDelayed(flushSteamGame, GAME_LAUNCH_RETRY_MS)
return
}
if (SessionState.phase != SessionPhase.READY) {
mainHandler.postDelayed(flushSteamGame, GAME_LAUNCH_RETRY_MS)
return
}
val directory = LinuxRuntime.sessionRoot(this)
val staged = File(directory, STEAM_GAME_REQUEST + ".tmp")
val request = File(directory, STEAM_GAME_REQUEST)
val written = runCatching {
directory.mkdirs()
staged.writeText("$gameId\n")
check(staged.renameTo(request)) { "could not publish Steam game request" }
}.onFailure { Log.w(TAG, "could not queue Steam game $gameId", it) }.isSuccess
if (!written) {
mainHandler.postDelayed(flushSteamGame, GAME_LAUNCH_RETRY_MS)
return
}
pendingSteamGameId = null
pendingSteamGameGeneration = -1
SessionEvents.record("steam.game_launch_requested", mapOf("gameId" to gameId, "generation" to gen))
Log.i(TAG, "queued Steam game $gameId for the running client")
}
private fun resumeSession() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
completeSteamSleep()
suspendAttemptFailed = false
updateSuspendPolicy()
}
private fun cancelQueuedSteamGame(clearMailbox: Boolean = true) {
mainHandler.removeCallbacks(flushSteamGame)
pendingSteamGameId = null
pendingSteamGameGeneration = -1
if (clearMailbox) clearSteamGameMailbox()
}
private fun clearSteamGameMailbox() {
val directory = LinuxRuntime.sessionRoot(this)
File(directory, STEAM_GAME_REQUEST).delete()
File(directory, STEAM_GAME_REQUEST + ".tmp").delete()
}
/**
* proot's --kill-on-exit takes its tracees down, but a session that died from the inside
* (the client asserting, Xwayland going) leaves gamescopereaper and the session script
@@ -938,9 +1025,26 @@ class SessionService : Service() {
private fun watchLaunchRequests(dir: File) {
launchWatcher?.stopWatching()
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready").forEach { File(dir, it).delete() }
val gen = sessionGen
@Suppress("DEPRECATION")
val watcher = object : android.os.FileObserver(dir.path, CLOSE_WRITE or MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (path == "steam-sleep") {
val request = File(dir, path)
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
request.delete()
if (!token.matches(Regex("[a-f0-9]{32}"))) return
mainHandler.post {
if (gen != sessionGen || !SessionState.running) return@post
steamSleepToken = token
writeSteamSleepState(token, "paused")
suspendAttemptFailed = false
Log.i(TAG, "Steam requested session sleep")
updateSuspendPolicy()
}
return
}
if (path != "steam-launch") return
val file = File(dir, path)
val text = try { file.readText() } catch (e: Exception) { return }
@@ -975,18 +1079,23 @@ class SessionService : Service() {
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
manualPauseRequested = true
}
val shouldSuspend = when (suspendPolicy) {
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
val controller = suspendController ?: return
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
val gen = sessionGen
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
))
if (shouldSuspend) {
controller.freeze { success ->
mainHandler.post {
if (!SessionState.running) return@post
if (gen != sessionGen || !SessionState.running) return@post
suspendOperationPending = false
if (success) {
SessionState.suspended = true
@@ -994,6 +1103,7 @@ class SessionService : Service() {
releaseLocks()
refreshNotification()
} else {
completeSteamSleep()
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session stopped")
}
@@ -1004,7 +1114,7 @@ class SessionService : Service() {
acquireLocks()
controller.resume { success ->
mainHandler.post {
if (!SessionState.running) return@post
if (gen != sessionGen || !SessionState.running) return@post
suspendOperationPending = false
if (success) {
SessionState.suspended = false
@@ -1020,6 +1130,27 @@ class SessionService : Service() {
}
}
private fun writeSteamSleepState(token: String, state: String) {
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-sleep-state.tmp")
staged.writeText("$token:$state\n")
check(staged.renameTo(File(dir, "steam-sleep-state")))
}.onFailure { Log.w(TAG, "could not acknowledge Steam sleep", it) }
}
private fun completeSteamSleep() {
val token = steamSleepToken ?: return
// Write before SIGCONT: the guest's login1 sees it immediately on thaw
// and delivers PrepareForSleep(false), restoring Steam's main surface.
writeSteamSleepState(token, "awake")
steamSleepToken = null
}
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy == SessionPrefs.SUSPEND_AUTO && activityVisible && screenOn) completeSteamSleep()
}
private fun finishSessionStop(status: Int, stoppedGen: Int) {
mainHandler.post {
if (stoppedGen != sessionGen || SessionState.running) {
@@ -1069,6 +1200,7 @@ class SessionService : Service() {
}
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
@@ -1275,6 +1407,7 @@ class SessionService : Service() {
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
const val ACTION_AGENT_START = "com.droiddeck.launcher.AGENT_START"
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
@@ -1291,6 +1424,8 @@ class SessionService : Service() {
private const val GRACE_MS = 1200L
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val GAME_LAUNCH_RETRY_MS = 250L
private const val STEAM_GAME_REQUEST = "steam-game"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
@@ -1309,6 +1444,7 @@ class SessionService : Service() {
* MODE_DESKTOP: "desktop" = open Steam's desktop client in the desktop once it is up. */
const val EXTRA_STEAM_UI = "steamUi"
const val EXTRA_STEAM_URL = "steamUrl"
const val EXTRA_GAME_ID = "gameId"
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
@@ -1342,6 +1478,20 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
}
/** Route a validated decimal Steam game id through the current guest session. */
fun launchGame(context: Context, gameId: String): Boolean {
if (!GameLaunchLink.validId(gameId) || !SessionState.running ||
SessionState.mode != MODE_STEAM || SessionState.stopRequested) return false
return runCatching {
context.startService(
Intent(context, SessionService::class.java)
.setAction(ACTION_LAUNCH_GAME)
.putExtra(EXTRA_GAME_ID, gameId),
)
true
}.getOrDefault(false)
}
fun suspendPolicyChanged(context: Context) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SUSPEND_POLICY_CHANGED))
@@ -69,8 +69,8 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
if (current == null) {
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
Rise(1) { Note(stringResource(R.string.games_empty)) }
Rise(2) {
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
if (!s.shortcutPicker) Rise(2) {
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
}
}
@@ -116,7 +116,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
@Composable
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
Actions {
PrimaryButton(stringResource(R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) }
}
@@ -125,10 +125,27 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
// Only games added to the library; Steam titles keep their saves with Steam Cloud.
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
}
if (!s.shortcutPicker) GameShortcutMenu(g, a)
BusyChip(s)
}
}
@Composable
private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) {
var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
Box {
SecondaryButton(stringResource(R.string.game_shortcut), compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_shortcut)) { first ->
MenuItem(stringResource(R.string.game_shortcut_add), checked = false, focusRequester = first) {
open = false; a.onGameShortcut(g)
}
MenuItem(stringResource(R.string.game_link_copy), checked = false) {
open = false; a.onCopyGameLink(g)
}
}
}
}
/**
* Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as
* either, or open the save folder found for it. The saves are looked for when the menu opens.
@@ -96,6 +96,8 @@ class FrontEndState(
val running: String?,
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
val lossless: Lossless.State = Lossless.State.NONE,
val shortcutPicker: Boolean = false,
val shortcutLibraryScanning: Boolean = false,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
val isHomeApp: Boolean = false,
@@ -126,6 +128,8 @@ class FrontEndActions(
val onPlay: () -> Unit,
val onPlayDesktopUi: () -> Unit,
val onSteamGame: (Library.SteamGame) -> Unit,
val onGameShortcut: (Library.SteamGame) -> Unit = {},
val onCopyGameLink: (Library.SteamGame) -> Unit = {},
val onDesktop: () -> Unit,
val onEmulator: (Library.Emulator) -> Unit,
/** A Flatpak app by id and name, from the Store. */
@@ -331,7 +335,8 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) {
var selected by rememberSaveable { mutableStateOf("steam") }
var selected by rememberSaveable { mutableStateOf(if (s.shortcutPicker) "games" else "steam") }
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) }
var wirelessAdbDesiredEnabled by rememberSaveable { mutableStateOf(false) }
@@ -54,6 +54,8 @@ class ModeSettings(
val gpuDrivers: String = "Auto",
/** Frames per second the session is capped at; 0 = none. */
val fpsLimit: Int = 0,
val upscaler: Int = 0,
val upscaleSharpness: Int = 75,
val touchMode: String,
val suspendPolicy: String,
/** Steam only. */
@@ -113,6 +115,8 @@ class ModeSettingsActions(
/** Opens the GPU drivers on the Components page: they are shared by every session. */
val onGpuDrivers: () -> Unit = {},
val onFpsLimit: (Int) -> Unit = {},
val onUpscaler: (Int) -> Unit = {},
val onUpscaleSharpness: (Int) -> Unit = {},
val onTouch: (String) -> Unit,
val onSuspendPolicy: (String) -> Unit,
val onOsc: (String) -> Unit,
@@ -189,6 +193,17 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = stringResource(R.string.mode_fps_note),
onPick = a.onFpsLimit,
)
ChoiceRow(
host, "upscaler", "Upscaler", "Sharpens the picture where it is enlarged to the screen.",
com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler,
note = "Works only when the session is smaller than the screen; Sharpen only works at any size. Costs a little GPU time.",
onPick = a.onUpscaler,
)
ChoiceRow(
host, "upscale-sharpness", "Upscaler sharpness", null,
com.droiddeck.launcher.session.SessionPrefs.upscaleSharpnessChoices, s.upscaleSharpness,
enabled = s.upscaler != 0, onPick = a.onUpscaleSharpness,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
@@ -171,6 +171,8 @@ class DrawerActions(
val fexPreset: String,
/** Steam only: games stretched to the screen's size, changed live (null = not Steam). */
val fillScreen: Boolean? = null,
val upscaler: Int = 0,
val upscaleSharpness: Int = 75,
val secondScreenMode: SecondScreenMode,
val secondScreenDisplays: List<SecondScreenDisplay>,
val selectedSecondScreenDisplay: Int,
@@ -192,6 +194,8 @@ class DrawerActions(
val onShape: (String) -> Unit,
val onFexPreset: (String) -> Unit,
val onFillScreen: (Boolean) -> Unit = {},
val onUpscaler: (Int) -> Unit = {},
val onUpscaleSharpness: (Int) -> Unit = {},
val onSecondScreenMode: (SecondScreenMode) -> Unit,
val onSecondScreenDisplay: (Int) -> Unit,
val onLaunchAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit,
@@ -355,6 +359,12 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen,
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
ChoiceRow(host, "upscaler", "Upscaler", null,
SessionPrefs.upscalerChoices, a.upscaler,
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
ChoiceRow(host, "upscale-sharpness", "Upscaler sharpness", null,
SessionPrefs.upscaleSharpnessChoices, a.upscaleSharpness, enabled = a.upscaler != 0,
chipModifier = focus.track(page, "upscale-sharpness"), onPick = a.onUpscaleSharpness)
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
+11
View File
@@ -909,4 +909,15 @@
<string name="game_env_change_title">Change variable</string>
<string name="game_env_restore_default">Back to default</string>
<string name="game_env_eyebrow">Proton games</string>
<string name="game_shortcut">Shortcut</string>
<string name="game_shortcut_add">Add to home screen</string>
<string name="game_shortcut_choose">Choose game</string>
<string name="game_shortcut_scanning">Scanning installed games…</string>
<string name="game_link_copy">Copy launch link</string>
<string name="game_link_copied">Launch link copied</string>
<string name="game_shortcut_unsupported">This launcher cannot add shortcuts. Use Copy launch link instead.</string>
<string name="game_link_invalid">Invalid game launch link.</string>
<string name="game_link_missing">This game is no longer in your DroidDeck library.</string>
<string name="game_link_session_busy">Stop the current session before launching a Steam game.</string>
<string name="game_link_failed">Could not send the game to Steam. Try launching it again.</string>
</resources>
@@ -0,0 +1,83 @@
package com.droiddeck.launcher.frontend
import android.content.Intent
import android.net.Uri
import android.app.Activity
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.frontend.Library
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
class GameLaunchLinkTest {
@Test fun acceptsCanonicalUnsignedDecimalIdsIncludingFullWidthShortcuts() {
val ids = listOf("1", "17410", "9223372036854775808", "18446744073709551615")
ids.forEach { id ->
assertTrue(GameLaunchLink.validId(id))
assertEquals(id, GameLaunchLink.parse(GameLaunchLink.uri(id)))
assertEquals("steam://rungameid/$id", GameLaunchLink.steamUrl(id))
}
}
@Test fun rejectsAnythingOtherThanOneCanonicalGamePath() {
listOf(
null, "", "droiddeck://game/0", "droiddeck://game/01", "droiddeck://game/+1",
"droiddeck://game/-1", "droiddeck://game/18446744073709551616",
"droiddeck://game/1?url=steam://rungameid/2", "droiddeck://game/1/../2",
"droiddeck://games/1", "droiddeck://game/1/", "DROIDDECK://game/1",
"droiddeck://game:4/1", "file:///game/1", "steam://rungameid/1",
).forEach { assertNull("should reject $it", GameLaunchLink.parse(it)) }
}
@Test fun convertsSignedStorageRepresentationToUnsignedShortcutUri() {
assertEquals("18446744073709551615", Library.SteamGame(1, "Added", null, Library.ADDED, -1L).gameIdString)
assertEquals("9223372036854775808", Library.SteamGame(1, "Added", null, Library.ADDED, Long.MIN_VALUE).gameIdString)
assertEquals("droiddeck://game/18446744073709551615", GameLaunchLink.uri(Library.SteamGame(1, "Added", null, Library.ADDED, -1L).gameIdString))
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GameShortcutIntentTest {
@Test fun shortcutLaunchIsExplicitAndReturnsThroughTheExistingMainActivity() {
val context = RuntimeEnvironment.getApplication()
val id = "18446744073709551615"
val intent = GameShortcuts.launchIntent(context, id)
assertEquals(Intent.ACTION_VIEW, intent.action)
assertEquals(Uri.parse("droiddeck://game/$id"), intent.data)
assertEquals(MainActivity::class.java.name, intent.component?.className)
assertEquals(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP, intent.flags)
}
@Test fun pinnedShortcutHasStableIdAndCanonicalExplicitLaunchIntent() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)
GameShortcuts.pin(activity, game)
val shortcut = activity.getSystemService(android.content.pm.ShortcutManager::class.java)
.pinnedShortcuts.single()
val launch = requireNotNull(shortcut.intent)
assertEquals("game:8400", shortcut.id)
assertEquals("Geometry Wars: Retro Evolved", shortcut.shortLabel.toString())
assertEquals(MainActivity::class.java.name, shortcut.activity?.className)
assertEquals(Intent.ACTION_VIEW, launch.action)
assertEquals(Uri.parse("droiddeck://game/8400"), launch.data)
assertEquals(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, launch.flags)
}
@Test fun actionCreateShortcutUsesAndroidsShortcutResultIntentApi() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)
assertNotNull(GameShortcuts.result(activity, game))
}
}
+49
View File
@@ -0,0 +1,49 @@
# Game launcher shortcuts
DroidDeck exposes installed Steam titles and AddedGames through a canonical launch link:
```text
droiddeck://game/<unsigned-decimal-game-id>
```
The ID is the value accepted by `steam://rungameid/`, including an AddedGames shortcut ID that occupies the unsigned 64-bit range. The app accepts only the exact URI form and only after a fresh library scan finds that game. It does not accept paths, Steam URLs, extra parameters, leading zeroes, or IDs outside the unsigned 64-bit range.
Each game page's **Shortcut** menu offers **Add to home screen** through Android's pinned-shortcut request and **Copy launch link**. When a launcher invokes `ACTION_CREATE_SHORTCUT`, DroidDeck opens the Games page and returns Android's standard shortcut result after the user chooses a game.
With the debug app installed, exercise link routing with an installed game ID from the current library:
```sh
adb shell am start -n com.droiddeck.launcher/.MainActivity \
-a android.intent.action.VIEW -d 'droiddeck://game/620'
```
Open the standard shortcut picker with:
```sh
adb shell am start -n com.droiddeck.launcher/.MainActivity \
-a android.intent.action.CREATE_SHORTCUT
```
A cold link starts the normal Steam session with `steam://rungameid/<id>` as its startup URL. A warm link is accepted only during a running Steam session. The service waits for READY, resumes a suspended session, then writes a decimal-only request for the existing Steam client to consume. Desktop and program sessions remain active and report that the game cannot be launched until the current session ends.
Build a full local debug APK, including the native/audio bundle, with:
```sh
DROIDDECK_BUILD_VARIANT=debug tools/build_local.sh
```
The focused JVM suite is `./gradlew app:testDebugUnitTest`. See the task report and session logs when validating cold launch, warm launch, resume, queueing, and rejection paths on a device.
## Thor acceptance run
Validated on the AYN Thor (Android 13 / API 33, serial `adb-d234a848-fv2FDl (2)._adb-tls-connect._tcp`). The full debug APK at `app/build/outputs/apk/debug/app-debug.apk` installed with `adb install -r --no-incremental`, without uninstalling or clearing app data.
- Pinned **Geometry Wars: Retro Evolved** from its game page, confirmed `game:8400` in Android's pinned shortcut database, and tapped that actual home-screen icon. The cold session reached READY with first frame; `Download/DroidDeck/2026-10-02-27-steam/session.log` records `steam://rungameid/8400`, Steam game ID 8400, `GeometryWars.exe`, and Gamescope focus on app 8400.
- Copied the link from the game menu. Android's clipboard preview showed `droiddeck://game/8400`. An ACTION_VIEW of that URI reused session `2026-10-02-27-steam`, guest PID 14740, and gamescope PID 14762; its event log recorded `steam.game_launch_requested` without a new compositor or guest process.
- Sent another game link while the generic Steam session was in `STARTING_STEAM`. Session `2026-10-02-28-steam` reached READY before recording the queued launch, and its guest log says it launched game 8400 through the running client.
- Pressing Home suspended the warm Steam session. Tapping the same pinned shortcut resumed session `2026-10-02-27-steam` with guest PID 14740 and dispatched the game request after `session.resumed`.
- Leading-zero, overflow-with-query, and unknown-ID links left the agent IDLE with no guest. A MODE_RUN session using `/usr/bin/sleep 300` remained the same session and program after opening the game link; no Steam launch was requested.
- ACTION_CREATE_SHORTCUT opened the Games picker on a fresh scan; choosing the title finished the picker. A shell-started activity has no caller to inspect the returned payload, so the end-to-end result extras were not directly captured on-device. Robolectric covers the result API call and shortcut intent metadata.
- The existing Game files and Proton prefix controls still opened Geometry Wars' installed folder and `compatdata/8400/pfx` in the file manager.
The device logs remain under `/sdcard/Download/DroidDeck/2026-10-02-27-steam` and `.../2026-10-02-28-steam`. Screenshots from the run are in `/tmp/game-launcher-thor-*.png`, `/tmp/files.png`, and `/tmp/prefix.png`. The only shortcut added for this run was `game:8400`; it was removed after validation.
+72
View File
@@ -0,0 +1,72 @@
# Mali software Steam experiment
This branch adds an opt-in CPU rendering path for Steam and Gamescope. Android's compositor selects system Vulkan automatically on non-Adreno hardware; explicit custom driver choices are retained. Linux uses Lavapipe for Gamescope and llvmpipe/software SDL for Steam. This is a UI compatibility fallback, not proof of accelerated game support.
Set `/sdcard/Download/droiddeck-env` to:
```sh
BL_STEAM_SOFTWARE=1
```
Launch **Steam → Play Steam**. The session selects Gamescope's SDL Wayland Vulkan swapchain, which can present Lavapipe frames through `wl_shm`. Gamescope's native Wayland backend requires exportable dma-bufs even for its blank texture. The new patch keeps locally sampled textures, including cursors and shared-memory client frames, private on swapchain backends and makes `VK_KHR_present_id`/`VK_KHR_present_wait` optional, using the existing timer pacing when they are unavailable. Drivers that support both features retain the present-wait thread. Shared-memory pixels are uploaded on every commit because clients reuse their buffers; the private textures are marked ready after the synchronous copy, avoiding an invalid dma-buf fence lookup.
Steam's embedded browser disables GPU rendering, and the native client uses the SDL software renderer and X11. The performance overlay is disabled in this mode.
## Building this branch
This experimental branch pins native CI run [37146942306](https://github.com/Droid-Deck/DroidDeck/actions/runs/37146942306) and its bundle checksum in `tools/gamescope/release.env`. Both the APK workflow and ordinary local build fetch and verify that artifact. No component release is published.
```sh
DROIDDECK_BUILD_VARIANT=debug tools/build_local.sh
```
To test a different successful native build, download its **gamescope-patched** artifact and verify the accompanying checksum:
```sh
gh run download <run-id> -R Droid-Deck/DroidDeck -n gamescope-patched -D /tmp/mali-gamescope
(cd /tmp/mali-gamescope && shasum -a 256 -c gamescope.tzst.sha256)
DROIDDECK_GAMESCOPE_BUNDLE=/tmp/mali-gamescope/gamescope.tzst \
DROIDDECK_BUILD_VARIANT=debug tools/build_local.sh
```
`DROIDDECK_GAMESCOPE_BUNDLE` overrides the pin for that local build. Before shipping, publish the native component and replace the temporary artifact pin with its release tag and checksum. CI artifacts have limited retention; this branch is an experiment, not a permanent release source.
The desktop path can also use the software Steam settings. On the tested G52 device, its labwc renderer requires `/sdcard/Download/droiddeck-wlr-renderer` containing `pixman`. Add `BL_DESKTOP_STEAM=1` only to start Steam automatically on the desktop.
## Device validation
Validated 2026-10-03 on the wired Unisoc T618 / Mali-G52 MC2 device (`RGB05001607258`, Android 12, kernel 5.4, 4 KiB pages). **Steam → Play Steam reaches the Big Picture QR sign-in screen inside Gamescope with only `BL_STEAM_SOFTWARE=1`.** The selected Deck mode was retained: the client command includes `-gamepadui -steamdeck -steamos3`. A test string was entered and cleared in the account field without submitting it; no account was signed in during that initial test. The desktop and desktop Steam QR screen also worked in the earlier test.
- Native bundle SHA-256: `22df550d60fcd62e351790eb775ee857e037fcd49fcc23a5bfa4638377e6b4b7`; the installed Gamescope binary matches the bundle (`41a067202e94af16b6f09b819208450e667dcc709480a2b97405d59a0e93cb69`).
- Signed debug APK SHA-256: `7a19928dc93e72c81b553c62515f3881a249eb037a81fbb6296e05bb9edd23b3`, built from commit `e427c08` using the ordinary artifact-pin path.
- Device logs: `Download/DroidDeck/2026-10-03-10-steam/`. They identify Lavapipe, SDL swapchain creation, timer pacing, and the outer window `Steam Big Picture Mode` drawing through the system Mali-G52 presenter.
- The native ARM build, full local Android build, bundle checksum, installed binary identity, shell syntax, workflow YAML, and diff checks passed. App data and the primary checkout were preserved.
The shared-memory commit fixes are both needed: without a ready fence, commits never display; with the old buffer memoization, a reused buffer keeps displaying its first copied pixels (the boot logo or black) despite ongoing frame delivery. Refreshing the copy also makes the account-field edits visible.
## Geometry Wars gameplay follow-up
After the user signed in, Geometry Wars: Retro Evolved (`8400`) was downloaded through the native Steam client on the same device and APK. Steam verified all seven files (66,019,943 bytes, build `251921`, depot `8401`). The first install confirmation crashed Steam with exit 139 while it queued additional compatibility runtimes. Restarting the session retained the install request; the download then completed successfully. The cause of that client crash is unverified.
The game launches through `bannerlator-proton-arm64` using the installed Proton Experimental (ARM64), Wine and DXVK, with the guest still using Lavapipe. It reached the animated menu and an active round; the on-screen A button starts a round, the left stick moves the ship, the right stick fires in different directions, and Start pauses it.
The first run was cropped and repeatedly recreated its swapchain. Turning off **Stretch games to fill** did not resolve the cropping. A game-specific Steam launch option did:
```sh
DISABLE_GAMESCOPE_WSI=1 %command%
```
That disables the Vulkan Xwayland-bypass layer for the game while keeping the outer Gamescope session. The subsequent menu and gameplay show the whole 4:3 image, including the high-score column and all menu entries. Fullscreen stretching remains off on the test device, and on-screen controls are set to **Always**. These are runtime settings; the branch does not automatically apply this per-game workaround.
The Android presenter reported roughly 3–5 displayed frames per second in representative gameplay samples. Movement and shooting work, but CPU-rendered performance is insufficient for normal play. Audio was not verified. The game was left paused with scrcpy running. Follow-up logs are in `Download/DroidDeck/2026-10-03-11-steam/`; screenshots and raw logs are retained locally, outside Git.
A guest glibc Mali ICD and compatible image transport remain necessary for hardware acceleration. This patch does not install Steam-ARM's feature layers or validate other Mali generations; the gameplay result establishes one Windows D3D9 title through software rendering, not general Proton/DXVK compatibility. The Android presenter also caps its requested Vulkan API to the loader's supported version and withholds dma-buf advertisement when the complete import extension set is missing; that missing-extension branch has not been exercised on this G52 driver.
## GPU acceleration follow-up
The [kbase/JM driver lab](../../tools/panvk/README.md) now includes a pinned
source build and three fixes validated on this wired G52. GPU compute,
offscreen draw/readback, indexed draw and visible VKCube presentation work.
Geometry Wars remains blocked: the bundled DXVK requires an unsupported
feature, while an isolated older DXVK reaches rendering and loses the GPU
device. The software Steam/Gamescope settings remain the recovery path.
+31 -6
View File
@@ -14,6 +14,12 @@ fi
sdk_dir=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-"${HOME}/Library/Android/sdk"}}
java_dir=${JAVA_HOME:-"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"}
image_name=${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v2}
build_variant=${DROIDDECK_BUILD_VARIANT:-release}
case "$build_variant" in
debug) gradle_task=assembleDebug ;;
release) gradle_task=assembleRelease ;;
*) echo "DROIDDECK_BUILD_VARIANT must be debug or release" >&2; exit 1 ;;
esac
if [[ ! -x "${sdk_dir}/platform-tools/adb" ]]; then
echo "Android SDK not found at ${sdk_dir}; set ANDROID_HOME or ANDROID_SDK_ROOT." >&2
@@ -201,10 +207,29 @@ cached() {
echo "${out}"
}
# Experimental branches can pin a CI artifact until their native component is released.
download_gamescope_artifact() (
set -e
local artifact_dir
artifact_dir=$(mktemp -d)
trap 'rm -rf "$artifact_dir"' EXIT
gh run download "$1" -R "$2" -n gamescope-patched -D "$artifact_dir"
cp "$artifact_dir/gamescope.tzst" "$out"
)
if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then
. "${repo_root}/tools/gamescope/release.env"
gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \
bash -c 'gh release download "$0" -R "$1" -p gamescope.tzst -O "$out"' "${GAMESCOPE_TAG}" "${github_repo}")
if [[ -n "${DROIDDECK_GAMESCOPE_BUNDLE:-}" ]]; then
gamescope_archive=${DROIDDECK_GAMESCOPE_BUNDLE}
test -f "${gamescope_archive}"
echo "Using local Gamescope bundle: ${gamescope_archive}"
elif [[ -n "${GAMESCOPE_ARTIFACT_RUN:-}" ]]; then
gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \
download_gamescope_artifact "${GAMESCOPE_ARTIFACT_RUN}" "${github_repo}")
else
gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \
bash -c 'gh release download "$0" -R "$1" -p gamescope.tzst -O "$out"' "${GAMESCOPE_TAG}" "${github_repo}")
fi
zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/bin/gamescope"
fi
@@ -285,11 +310,11 @@ bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
cd "${repo_root}"
./gradlew assembleRelease --console=plain -PndkVersion="${ndk_version}"
./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}"
cp -p "${bundle_backup}" "${bundle_asset}"
bundle_replaced=0
apk="${repo_root}/app/build/outputs/apk/release/app-release.apk"
apk="${repo_root}/app/build/outputs/apk/${build_variant}/app-${build_variant}.apk"
audio_check="${staging_dir}/audio-check"
mkdir -p "${audio_check}"
unzip -p "${apk}" assets/pulseaudio.tzst | zstd -dc | tar -xf - -C "${audio_check}"
@@ -303,10 +328,10 @@ for audio_file in \
fi
done
docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${image_name}" \
docker run --rm --platform linux/amd64 -e build_variant="${build_variant}" -v "${repo_root}:/src:ro" -w /src "${image_name}" \
bash -lc '
set -euo pipefail
apk=app/build/outputs/apk/release/app-release.apk
apk=app/build/outputs/apk/${build_variant}/app-${build_variant}.apk
work=$(mktemp -d)
unzip -q "$apk" "lib/arm64-v8a/*" -d "$work"
cd "$work/lib/arm64-v8a"
@@ -0,0 +1,132 @@
diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp
index 54e4efc..88a6353 100644
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -450,7 +450,13 @@ bool CVulkanDevice::createDevice()
bool hasDrmProps = vulkan_has_drm_props();
bool supportsForeignQueue = false;
bool supportsHDRMetadata = false;
+ bool supportsPresentId = false;
+ bool supportsPresentWait = false;
for (const auto& ext : m_supportedExts) {
+ if ( strcmp(ext.extensionName, VK_KHR_PRESENT_ID_EXTENSION_NAME) == 0 )
+ supportsPresentId = true;
+ if ( strcmp(ext.extensionName, VK_KHR_PRESENT_WAIT_EXTENSION_NAME) == 0 )
+ supportsPresentWait = true;
if ( strcmp(ext.extensionName, VK_EXT_IMAGE_DRM_FORMAT_MODIFIER_EXTENSION_NAME) == 0 )
m_bSupportsModifiers = true;
@@ -518,6 +524,25 @@ bool CVulkanDevice::createDevice()
m_bSupportsModifiers = false;
}
+ if ( supportsPresentId && supportsPresentWait )
+ {
+ VkPhysicalDevicePresentWaitFeaturesKHR waitFeatures = {
+ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_WAIT_FEATURES_KHR,
+ };
+ VkPhysicalDevicePresentIdFeaturesKHR idFeatures = {
+ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_ID_FEATURES_KHR,
+ .pNext = &waitFeatures,
+ };
+ VkPhysicalDeviceFeatures2 features = {
+ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
+ .pNext = &idFeatures,
+ };
+ vk.GetPhysicalDeviceFeatures2( physDev(), &features );
+ m_bSupportsPresentWait = idFeatures.presentId && waitFeatures.presentWait;
+ }
+ if ( GetBackend()->UsesVulkanSwapchain() && !m_bSupportsPresentWait )
+ vk_log.infof( "Present ID/wait unavailable; using timer-based pacing." );
+
{
VkPhysicalDeviceVulkan12Features vulkan12Features = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES,
@@ -570,8 +595,11 @@ bool CVulkanDevice::createDevice()
enabledExtensions.push_back( VK_KHR_SWAPCHAIN_EXTENSION_NAME );
enabledExtensions.push_back( VK_KHR_SWAPCHAIN_MUTABLE_FORMAT_EXTENSION_NAME );
- enabledExtensions.push_back( VK_KHR_PRESENT_ID_EXTENSION_NAME );
- enabledExtensions.push_back( VK_KHR_PRESENT_WAIT_EXTENSION_NAME );
+ if ( m_bSupportsPresentWait )
+ {
+ enabledExtensions.push_back( VK_KHR_PRESENT_ID_EXTENSION_NAME );
+ enabledExtensions.push_back( VK_KHR_PRESENT_WAIT_EXTENSION_NAME );
+ }
if ( supportsHDRMetadata )
enabledExtensions.push_back( VK_EXT_HDR_METADATA_EXTENSION_NAME );
@@ -646,7 +674,7 @@ bool CVulkanDevice::createDevice()
VkPhysicalDeviceFeatures2 features2 = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
- .pNext = &presentIdFeatures,
+ .pNext = m_bSupportsPresentWait ? static_cast<void *>( &presentIdFeatures ) : static_cast<void *>( &features13 ),
.features = {
.shaderInt16 = m_bSupportsFp16,
},
@@ -2082,6 +2110,12 @@ static VkImageViewType VulkanImageTypeToViewType(VkImageType type)
bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA /* = nullptr */, uint32_t contentWidth /* = 0 */, uint32_t contentHeight /* = 0 */, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc<gamescope::IBackendFb> pBackendFb )
{
+ // Swapchain backends composite into their own images; textures are never scanned out.
+ // Keep explicit exports and dma-buf imports, but do not require a DRM export for local
+ // cursor, blank, or wl_shm textures (Lavapipe has no DRM allocator).
+ if ( GetBackend()->UsesVulkanSwapchain() )
+ flags.bFlippable = false;
+
m_pBackendFb = std::move( pBackendFb );
m_drmFormat = drmFormat;
VkResult res = VK_ERROR_INITIALIZATION_FAILED;
@@ -3086,7 +3120,7 @@ void vulkan_present_to_window( void )
VkPresentInfoKHR presentInfo = {
.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,
- .pNext = &presentIdInfo,
+ .pNext = g_device.supportsPresentWait() ? &presentIdInfo : nullptr,
.swapchainCount = 1,
.pSwapchains = &g_output.swapChain,
.pImageIndices = &g_output.nOutImage,
@@ -3094,8 +3128,11 @@ void vulkan_present_to_window( void )
if ( g_device.vk.QueuePresentKHR( g_device.queue(), &presentInfo ) == VK_SUCCESS )
{
- g_currentPresentWaitId = presentId;
- g_currentPresentWaitId.notify_all();
+ if ( g_device.supportsPresentWait() )
+ {
+ g_currentPresentWaitId = presentId;
+ g_currentPresentWaitId.notify_all();
+ }
}
else
vulkan_remake_swapchain();
@@ -3662,7 +3699,7 @@ bool vulkan_init( VkInstance instance, VkSurfaceKHR surface )
if (!init_nis_data())
return false;
- if ( GetBackend()->UsesVulkanSwapchain() )
+ if ( GetBackend()->UsesVulkanSwapchain() && g_device.supportsPresentWait() )
{
std::thread present_wait_thread( present_wait_thread_func );
present_wait_thread.detach();
diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp
index fa519db..91f7793 100644
--- a/src/rendervulkan.hpp
+++ b/src/rendervulkan.hpp
@@ -862,6 +862,7 @@ public:
inline VkPipelineLayout pipelineLayout() {return m_pipelineLayout;}
inline int drmRenderFd() {return m_drmRendererFd;}
inline bool supportsModifiers() {return m_bSupportsModifiers;}
+ inline bool supportsPresentWait() {return m_bSupportsPresentWait;}
inline bool hasDrmPrimaryDevId() {return m_bHasDrmPrimaryDevId;}
inline dev_t primaryDevId() {return m_drmPrimaryDevId;}
inline bool supportsFp16() {return m_bSupportsFp16;}
@@ -930,6 +931,7 @@ protected:
uint32_t m_uVendorID = 0;
bool m_bHasDrmPrimaryDevId = false;
bool m_bSupportsModifiers = false;
+ bool m_bSupportsPresentWait = false;
bool m_bInitialized = false;
@@ -0,0 +1,56 @@
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 18b799e..26cf64e 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -1850,19 +1850,20 @@ import_commit (
commit->fifo = false;
}
- if ( gamescope::OwningRc<CVulkanTexture> pTexture = s_BufferMemos.LookupVulkanTexture( buf ) )
- {
- // Going from OwningRc -> Rc now.
- commit->vulkanTex = pTexture;
- return commit;
- }
-
struct wlr_dmabuf_attributes dmabuf = {0};
+ const bool bHasDmabuf = wlr_buffer_get_dmabuf( buf, &dmabuf );
gamescope::OwningRc<gamescope::IBackendFb> pBackendFb;
- if ( wlr_buffer_get_dmabuf( buf, &dmabuf ) )
+ if ( bHasDmabuf )
{
+ if ( gamescope::OwningRc<CVulkanTexture> pTexture = s_BufferMemos.LookupVulkanTexture( buf ) )
+ {
+ commit->vulkanTex = pTexture;
+ return commit;
+ }
pBackendFb = GetBackend()->ImportDmabufToBackend( &dmabuf );
}
+ // A dma-buf image aliases its backing storage. A wl_shm texture is a private copy:
+ // upload each commit, since clients reuse the same buffer with different pixels.
gamescope::OwningRc<CVulkanTexture> pOwnedTexture = vulkan_create_texture_from_wlr_buffer( buf, std::move( pBackendFb ) );
@@ -1873,7 +1874,8 @@ import_commit (
commit->vulkanTex = pOwnedTexture;
- s_BufferMemos.MemoizeBuffer( buf, std::move( pOwnedTexture ) );
+ if ( bHasDmabuf )
+ s_BufferMemos.MemoizeBuffer( buf, std::move( pOwnedTexture ) );
return commit;
}
@@ -8504,6 +8506,12 @@ void update_wayland_res(CommitDoneList_t *doneCommits, steamcompmgr_win_t *w, Re
{
fence = dup( dmabuf.fd[0] );
}
+ else if ( !newCommit->vulkanTex->externalImage() )
+ {
+ // wl_shm uploads wait for their copy to finish in vulkan_create_texture_from_wlr_buffer.
+ // A private swapchain texture has no dma-buf to poll; it is already ready.
+ bKnownReady = true;
+ }
else
{
fence = newCommit->vulkanTex->memoryFence();
+4 -1
View File
@@ -1,2 +1,5 @@
# Experimental branch pin: build the tested native patch without publishing a component release.
# Before shipping, publish a Gamescope release and remove GAMESCOPE_ARTIFACT_RUN.
GAMESCOPE_ARTIFACT_RUN=37146942306
GAMESCOPE_SHA256=22df550d60fcd62e351790eb775ee857e037fcd49fcc23a5bfa4638377e6b4b7
GAMESCOPE_TAG=gamescope-3.16.29-p5
GAMESCOPE_SHA256=188144e5564e020d0bc485a1f4f90e1c5fd61d0d1d6dd29db21e8b019036a5c6
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/python3
"""Steam's login1 sleep notifications, backed by DroidDeck's session pause.
Android owns device power. Suspend here pauses the guest through SessionService;
the app's Resume action thaws it and completes PrepareForSleep(false). Steam
must see this service before startup or it can stay black after its idle timer.
"""
import os
from pathlib import Path
import sys
import uuid
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
NAME = "org.freedesktop.login1"
ROOT = "/org/freedesktop/login1"
INTERFACE = NAME + ".Manager"
INTROSPECTION = """
<node>
<interface name="org.freedesktop.login1.Manager">
<method name="Suspend"><arg type="b" direction="in"/></method>
<method name="SuspendWithFlags"><arg type="t" direction="in"/></method>
<method name="CanSuspend"><arg type="s" direction="out"/></method>
<property name="PreparingForSleep" type="b" access="read"/>
<signal name="PrepareForSleep"><arg type="b"/></signal>
</interface>
</node>
"""
class LoginManager:
def __init__(self, bus, directory):
self.bus = bus
self.directory = Path(directory)
self.request = self.directory / "steam-sleep"
self.state = self.directory / "steam-sleep-state"
self.pending = None
self.timeout = None
info = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION).interfaces[0]
self.registration = bus.register_object(ROOT, info, self.method, self.property, None)
self.monitor = Gio.File.new_for_path(str(self.directory)).monitor_directory(Gio.FileMonitorFlags.NONE, None)
self.monitor.connect("changed", self.changed)
def property(self, connection, sender, path, interface, name):
if name == "PreparingForSleep":
return GLib.Variant("b", self.pending is not None)
return None
def notify(self, sleeping):
self.bus.emit_signal(None, ROOT, INTERFACE, "PrepareForSleep", GLib.Variant("(b)", (sleeping,)))
self.bus.emit_signal(None, ROOT, "org.freedesktop.DBus.Properties", "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (INTERFACE, {
"PreparingForSleep": GLib.Variant("b", sleeping),
}, [])))
self.bus.flush_sync(None)
print("== login1: " + ("pausing the session" if sleeping else "session resumed"), flush=True)
def method(self, connection, sender, path, interface, name, args, reply):
if name == "CanSuspend":
reply.return_value(GLib.Variant("(s)", ("yes",)))
elif name in ("Suspend", "SuspendWithFlags"):
if self.pending:
reply.return_dbus_error(NAME + ".OperationInProgress", "The session is already pausing")
return
self.pending = uuid.uuid4().hex
self.notify(True)
# Complete the D-Bus call before the host can freeze this service and
# its caller. Publishing on the next main-loop turn avoids that race.
reply.return_value(None)
self.bus.flush_sync(None)
GLib.idle_add(self.publish)
def publish(self):
try:
staged = self.request.with_suffix(".tmp")
staged.write_text(self.pending + "\n")
staged.replace(self.request)
self.timeout = GLib.timeout_add_seconds(5, self.check_ack)
except OSError as error:
print("== login1: cannot request pause: " + str(error), file=sys.stderr)
self.resume()
return False
def changed(self, monitor, file, other, event):
if file.get_basename() == self.state.name or (other and other.get_basename() == self.state.name):
self.check_state()
def read_state(self):
try:
return self.state.read_text().strip()
except OSError:
return ""
def check_state(self):
if self.pending and self.read_state() == self.pending + ":awake":
self.resume()
def check_ack(self):
self.timeout = None
if self.pending and self.read_state() != self.pending + ":paused":
# A missing host or a failed freeze must not strand Steam in black.
self.request.unlink(missing_ok=True)
self.resume()
return False
def resume(self):
if self.pending is None:
return
self.pending = None
if self.timeout is not None:
GLib.source_remove(self.timeout)
self.timeout = None
self.notify(False)
def main():
directory = os.environ.get("BL_LAUNCH_DIR")
if not directory or not Path(directory).is_dir():
sys.exit("bannerlator-login1: no DroidDeck session directory")
ready = Path(directory) / "steam-sleep-ready"
ready.unlink(missing_ok=True)
loop = GLib.MainLoop()
manager = []
def connected(bus, name):
manager.append(LoginManager(bus, directory))
def acquired(bus, name):
ready.write_text("ready\n")
def lost(bus, name):
ready.unlink(missing_ok=True)
loop.quit()
owner = Gio.bus_own_name(Gio.BusType.SYSTEM, NAME, Gio.BusNameOwnerFlags.NONE, connected, acquired, lost)
try:
loop.run()
finally:
ready.unlink(missing_ok=True)
Gio.bus_unown_name(owner)
if __name__ == "__main__":
main()
@@ -73,6 +73,15 @@ if [ -n "${BL_VK_DRIVER:-}" ]; then
fi
unset bl_icd_lib
fi
# Compatibility mode: the Android presenter still uses its selected Vulkan driver, while the
# Linux session renders on the CPU. Gamescope's SDL swapchain can carry these frames as wl_shm;
# its Wayland backend requires exportable dma-bufs even for its dummy black texture.
if [ "${BL_STEAM_SOFTWARE:-0}" = 1 ]; then
export VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json
export VK_ICD_FILENAMES=$VK_DRIVER_FILES
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe MESA_LOADER_DRIVER_OVERRIDE=swrast
echo "== linux graphics: software rendering requested (Lavapipe/llvmpipe)"
fi
# The app's gamescope build (tools/gamescope) asks for realtime-priority Vulkan queues on this
# switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority
# where the driver refuses, and the runtime's own build ignores it. On KGSL Turnip (Mesa 26.2) it
@@ -148,7 +157,13 @@ if [ -z "${BL_INSIDE:-}" ]; then
echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output"
fi
fi
args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height")
backend=wayland
if [ "${BL_STEAM_SOFTWARE:-0}" = 1 ]; then
backend=sdl
export SDL_VIDEODRIVER=wayland
echo "== gamescope: SDL Wayland swapchain for software rendering"
fi
args=(--backend "$backend" --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height")
[ "$nested" != "$width" ] && args+=(-S stretch)
# -r is what gamescope advertises as the refresh rate, and what a game reads as the display's.
# Left unset it defaults to 60, so a 120 Hz panel offers only 60 Hz in game settings. The FPS
@@ -179,7 +194,8 @@ if [ -z "${BL_INSIDE:-}" ]; then
# MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu
# still offers the levels and nothing appears. Starts hidden until the client writes a level.
# BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip).
if [ "$mode" = steam ] && [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_MANGOAPP:-1}" = 1 ] \
if [ "$mode" = steam ] && [ "${BL_STEAM_SOFTWARE:-0}" != 1 ] \
&& [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_MANGOAPP:-1}" = 1 ] \
&& [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then
MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)
echo no_display > "$MANGOHUD_CONFIGFILE"
@@ -564,11 +580,29 @@ BUSCONF
bannerlator-netmanager &
netmanager=$!
echo "== network: system bus up, NetworkManager stand-in running"
# Steam blanks its main UI before calling login1.Suspend. Without the
# matching resume notification it stays black although the menus work.
# Own the name before Steam constructs its login1 proxy at startup.
rm -f "$BL_LAUNCH_DIR/steam-sleep-ready"
bannerlator-login1 &
login_manager=$!
for ((_i = 0; _i < 50; _i++)); do
[ -f "$BL_LAUNCH_DIR/steam-sleep-ready" ] && break
kill -0 "$login_manager" 2>/dev/null || break
nap 0.1
done
if [ ! -f "$BL_LAUNCH_DIR/steam-sleep-ready" ]; then
echo "== sleep: login1 did not start; refusing a client that cannot resume"
exit 69
fi
echo "== sleep: login1 ready, Android session pause/resume available"
else
echo "== network: the system bus did not come up; the client will show no connection"
exit 69
fi
else
echo "== network: no dbus-daemon or netmanager in this runtime; the client will show no connection"
exit 69
fi
export STEAM_RUNTIME=1
@@ -621,6 +655,15 @@ BUSCONF
# desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
_bl_ui=(-gamepadui)
[ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=()
# Software compatibility mode: CEF and Steam use CPU frames through Xwayland,
# inside either the desktop or the software Gamescope session.
_bl_cef=(-cef-force-gpu -cef-use-gl=angle -cef-use-angle=vulkan)
if [ "${BL_STEAM_SOFTWARE:-0}" = 1 ]; then
_bl_cef=(-cef-disable-gpu -cef-disable-gpu-compositing)
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
export MESA_LOADER_DRIVER_OVERRIDE=swrast SDL_RENDER_DRIVER=software SDL_VIDEODRIVER=x11
echo "== steam: software rendering requested"
fi
# Deck mode (BL_STEAMDECK=1, the Steam page's Client settings): -steamdeck is the flag that puts
# the Quick Access Menu and the Deck settings pages on screen; the QAM's performance overlay
# controls stay greyed out without -steamos3, as SteamOS and Armada launch it. -steamos3 makes
@@ -680,13 +723,13 @@ BUSCONF
done
) &
fi
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-ozone-platform=x11 ${_bl_cef[*]} $*"
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
steam_updates=0
rm -f "$HOME/.bl-stop-requested"
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -d "$BL_LAUNCH_DIR" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop"
rm -f "$BL_LAUNCH_DIR/steam-stop" "$BL_LAUNCH_DIR/steam-game" "$BL_LAUNCH_DIR/steam-game.tmp"
(
while :; do
if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then
@@ -696,6 +739,24 @@ BUSCONF
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
exit 0
fi
if [ -f "$BL_LAUNCH_DIR/steam-game" ]; then
game_id=$(cat "$BL_LAUNCH_DIR/steam-game" 2>/dev/null)
rm -f "$BL_LAUNCH_DIR/steam-game"
case $game_id in
''|0*|*[!0-9]*) echo "== steam: ignored invalid game launch request" ;;
*)
game_id_length=${#game_id}
if [ "$game_id_length" -lt 20 ] || {
[ "$game_id_length" -eq 20 ] && [[ "$game_id" < "18446744073709551616" ]];
}; then
echo "== steam: launching requested game $game_id in the running client"
"$steam_root/steamrtarm64/steam" "steam://rungameid/$game_id" >/dev/null 2>&1 &
else
echo "== steam: ignored out-of-range game launch request"
fi
;;
esac
fi
nap 0.5
done
) &
@@ -709,8 +770,7 @@ BUSCONF
fi
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \
-cef-use-gl=angle -cef-use-angle=vulkan "$@"
-no-cef-sandbox -cef-ozone-platform=x11 "${_bl_cef[@]}" "$@"
rc=$?
echo "== steam exited rc=$rc"
collect_steam_logs
@@ -13,6 +13,7 @@
# On a software desktop (pixman: the GPU renderers failed on this device) there is no dma-buf, so a
# game - or the client's own Vulkan window - would die at its first frame. There the request goes
# to the app, which ends the desktop and starts a Steam session under gamescope in its place.
# BL_STEAM_SOFTWARE=1 opts into a CPU-rendered client on that desktop for compatibility testing.
set -u
ui=${1:-desktop}
target=${2:-}
@@ -28,7 +29,8 @@ esac
steam_root=${XDG_DATA_HOME:-${HOME:-/root}/.local/share}/Steam
client=$steam_root/steamrtarm64/steam
if [ "${BL_DESKTOP:-}" = 1 ] && [ -n "${WLR_RENDERER:-}" ] && [ "$WLR_RENDERER" != pixman ]; then
if [ "${BL_DESKTOP:-}" = 1 ] && [ -n "${WLR_RENDERER:-}" ] \
&& { [ "$WLR_RENDERER" != pixman ] || [ "${BL_STEAM_SOFTWARE:-0}" = 1 ]; }; then
log=${BL_DEBUG_DIR:-$HOME/.cache/droiddeck}/steam-desktop.log
mkdir -p "$(dirname "$log")" 2>/dev/null
if pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1 && [ -x "$client" ]; then
+118
View File
@@ -0,0 +1,118 @@
# Experimental Mali kbase/JM driver
This lab builds the Linux Vulkan ICD from
[FristOneRR-Panvk-Source](https://github.com/FristOneRR-Admin/FristOneRR-Panvk-Source)
at `efd07bab401b263a9ac114560ecdb9a2934f5d43`. The fork includes the older Job
Manager submission path needed by Mali-G52. It talks to `/dev/mali0` through
the proprietary kbase kernel interface; upstream DRM-only PanVK cannot use
that device node.
The local patches make Android `liblog` conditional, fix the JM atom ABI,
and withhold placed mapping on kbase.
The original fork padded its v2 layout to 64 bytes, which this kernel interprets
as v3 with an eight-byte sequence-number prefix. Jobs arrived under atom 0 and
timed out after a configuration fault. The patch submits the actual 56-byte v2
structure on JM 11.19 and later (48 bytes on older JM), as specified in
[Arm's kbase UAPI header](https://nest-open-source.googlesource.com/manifest_repos/mali-driver/+/0f8397eced2de6bc649a9cc32d0fae77a1dc34dc/bifrost/r44p0/kernel/include/uapi/gpu/arm/midgard/jm/mali_base_jm_kernel.h).
Build inside an AArch64 glibc environment with Clang/LLVM 22, Meson, Ninja,
libclc, SPIRV-Tools, SPIRV-LLVM-Translator, Vulkan headers, Python Mako/YAML,
and the X11/Wayland development dependencies installed. The tested DroidDeck
runtime already supplied several of these dependencies. Avoid installing an
entire development toolchain into a user's runtime by default.
```sh
git clone https://github.com/FristOneRR-Admin/FristOneRR-Panvk-Source.git panvk-src
git -C panvk-src checkout efd07bab401b263a9ac114560ecdb9a2934f5d43
JOBS=2 tools/panvk/build-in-runtime.sh "$PWD/panvk-src" "$PWD/panvk-build"
```
The build stays under `panvk-build`; it does not install or replace a global
ICD. Its manifest selects the newly built driver explicitly:
```sh
PAN_I_WANT_A_BROKEN_VULKAN_DRIVER=1 PANVK_KBASE_DRI3=0 \
VK_DRIVER_FILES="$PWD/panvk-build/panvk-g52-icd.json" \
VK_ICD_FILENAMES="$PWD/panvk-build/panvk-g52-icd.json" \
vulkaninfo --summary
```
To launch the tested GPU cube from the Mac against this device's lab build:
```sh
ADB_SERIAL=RGB05001607258 tools/droiddeckctl run /usr/bin/env -- \
PAN_I_WANT_A_BROKEN_VULKAN_DRIVER=1 PANVK_KBASE_DRI3=0 \
VK_DRIVER_FILES=/opt/panvk-g52-lab/panvk-g52-icd.json \
VK_ICD_FILENAMES=/opt/panvk-g52-lab/panvk-g52-icd.json \
DISABLE_GAMESCOPE_WSI=1 LD_LIBRARY_PATH=/usr/lib \
/usr/bin/vkcube --wsi xcb --width 640 --height 480
```
Stop the current session before using `droiddeckctl run`.
`PANVK_KBASE_DRI3=0` selects shared-memory presentation. DroidDeck's Xwayland
does not implement the fork's private Termux:X11 raw dma-buf protocol. GPU
rendering and CPU presentation copies are separate: choosing this presentation
path does not select Lavapipe.
Before selecting this driver for a session, validate GPU compute and offscreen
render readback, then X11 presentation, then the desired game. Device detection
alone is insufficient. The wired Unisoc T618 / Mali-G52 MC2 reports kbase UAPI
11.31, while the separate
[G52 reference project](https://github.com/LukeValen/panvk-mali-g52) was tested
on MediaTek with UAPI 11.38. Keep `BL_STEAM_SOFTWARE=1` available for recovery.
The Linux ICD is distinct from the Android Vulkan driver used by DroidDeck's
presenter. Android/Bionic driver packages cannot be loaded by the glibc Steam
runtime.
## Wired G52 results (2026-10-03)
Built natively in DroidDeck's AArch64 glibc runtime with Clang/LLVM 22.1.8.
The app UID can open `/dev/mali0` and submit jobs without root. No DRM render
node or dma-heap was available. This is experimental driver work, with the
Steam UI and outer Gamescope still running in software mode.
| Test | Result |
| --- | --- |
| Vulkan enumeration | Mali-G52 r1 MC2, integrated GPU, Vulkan 1.3.354 |
| Compute shader and host readback | Expected integer 777 returned |
| Vertex/fragment draw and image readback | Expected red pixel 255,0,0,255 returned |
| Indexed triangle and image readback | Expected red pixel returned |
| X11 VKCube inside a DroidDeck Gamescope session | Rotating cube visible; about 15–17 displayed FPS through SHM |
| Geometry Wars, bundled DXVK 3.1.1 | Adapter rejected: `multiDrawIndirect` is unsupported |
| Geometry Wars, isolated DXVK 1.10.3 | Device and 640×480 swapchain created; rendering loses the GPU device |
The fork advertises `VK_EXT_map_memory_placed` although its kbase mapping code
rejects caller-selected addresses for ordinary SAME_VA allocations. Wine's
WoW64 Vulkan path selects that extension and DXVK buffer mapping fails with
`VK_ERROR_MEMORY_MAP_FAILED`. The third patch withholds the extension and
features on kbase, keeping them on DRM. Wine then uses its fallback mapping
path and reaches real game submissions.
The remaining Geometry Wars failure includes terminated jobs (`0x04`) and
`BASE_JD_EVENT_JOB_READ_FAULT` (`0x42`), followed by
`VK_ERROR_DEVICE_LOST`. A diagnostic build that waits after each batch showed
successful uploads and clears, then termination of the first vertex/tiler
chain. Its compute job completed, but its vertex job, tiler job and dependent
fragment job did not complete. Basic indexed rendering also passes with IDVS
disabled, so a generic indexed-draw or separate-vertex-job failure was not
reproduced. The complex vertex workload remains unresolved. Disabling submission overlap and compiler IDVS or
optimizations did not recover rendering. Native draw/readback and VKCube
success therefore do not establish working Proton game acceleration.
Do not advertise missing Vulkan features just to pass DXVK's adapter check.
Keep game overrides and legacy DXVK trials isolated from the Proton depot,
and restore the working software launch option when the trial fails:
```sh
DISABLE_GAMESCOPE_WSI=1 %command%
```
The tested three-patch build has SHA-256
`5fc3f57fe335ebf524871ca24fd38b3c384c31baaebe9a2220e8e482e09e5ad6`.
That identifies the retained evidence binary; rebuilds need not be byte-identical.
The patched driver remains a private lab ICD. It is not bundled in the APK,
selected globally, or production-ready. A compatible DXVK path and reliable
complex rendering must be validated before adding automatic Mali selection.
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Experimental kbase/JM PanVK build. Run inside an AArch64 glibc environment.
# Output stays private to the supplied build directory; no system ICD is installed.
set -euo pipefail
source_dir=${1:?Usage: build-in-runtime.sh SOURCE_DIR BUILD_DIR}
build_dir=${2:?Usage: build-in-runtime.sh SOURCE_DIR BUILD_DIR}
revision=efd07bab401b263a9ac114560ecdb9a2934f5d43
script_dir=$(cd -- "$(dirname -- "$0")" && pwd)
source_dir=$(cd -- "$source_dir" && pwd)
mkdir -p "$build_dir"
build_dir=$(cd -- "$build_dir" && pwd)
if [ "$(uname -m)" != aarch64 ]; then
echo 'This script needs a native AArch64 Linux build environment.' >&2
exit 1
fi
if [ "$(git -C "$source_dir" rev-parse HEAD)" != "$revision" ]; then
echo "Expected FristOneRR-Panvk-Source revision $revision." >&2
exit 1
fi
for patch_file in "$script_dir"/patches/*.patch; do
if git -C "$source_dir" apply --reverse --check "$patch_file" 2>/dev/null; then
continue
fi
git -C "$source_dir" apply --check "$patch_file"
git -C "$source_dir" apply "$patch_file"
done
# The fork's SPIRV-Tools probe contains Clang-only warning options.
export CC=clang CXX=clang++
meson setup "$build_dir" "$source_dir" --buildtype=release \
--prefix="$build_dir/install" --libdir=lib \
-Dbuild-tests=false -Dgallium-drivers= -Dvulkan-drivers=panfrost \
-Dpanfrost-kmds=kbase -Dplatforms=x11,wayland \
-Dglx=disabled -Degl=disabled -Dgbm=disabled \
-Dgles1=disabled -Dgles2=disabled -Dopengl=false \
-Dllvm=enabled -Dmesa-clc=enabled -Dprecomp-compiler=enabled \
-Dpanfrost-rust=false -Dvalgrind=disabled -Dzstd=enabled
ninja -C "$build_dir" -j"${JOBS:-2}" src/panfrost/vulkan/libvulkan_panfrost.so
python3 - "$build_dir" <<'PY'
import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
driver = root / 'src/panfrost/vulkan/libvulkan_panfrost.so'
manifest = {'file_format_version': '1.0.0', 'ICD': {
'library_path': str(driver), 'api_version': '1.4.0'}}
(root / 'panvk-g52-icd.json').write_text(json.dumps(manifest, indent=2) + '\n')
PY
sha256sum "$build_dir/src/panfrost/vulkan/libvulkan_panfrost.so"
echo "Private ICD: $build_dir/panvk-g52-icd.json"
+17
View File
@@ -0,0 +1,17 @@
diff --git a/meson.build b/meson.build
--- a/meson.build
+++ b/meson.build
@@ -16,10 +16,9 @@ project(
'build.rust_std=2021',
],
)
-add_project_link_arguments(
- '-llog',
- language : ['c', 'cpp'],
-)
+if host_machine.system() == 'android'
+ add_project_link_arguments('-llog', language : ['c', 'cpp'])
+endif
project_version = meson.project_version()
if host_machine.system() == 'darwin'
@@ -0,0 +1,95 @@
diff --git a/src/panfrost/lib/kmod/kbase_kmod.c b/src/panfrost/lib/kmod/kbase_kmod.c
--- a/src/panfrost/lib/kmod/kbase_kmod.c
+++ b/src/panfrost/lib/kmod/kbase_kmod.c
@@ -97,6 +97,7 @@
struct base_jd_udata { __u64 blob[2]; };
+/* kbase atom v2: 56 bytes, without the v3 sequence-number prefix. */
struct base_jd_atom {
__u64 jc;
struct base_jd_udata udata;
@@ -110,11 +111,12 @@
__u8 jobslot;
__u32 core_req;
__u8 renderpass_id;
- __u8 padding[15]; /* pad struct to 64 bytes total; this kernel rejects any other JOB_SUBMIT stride */
+ __u8 padding[7];
};
-/* Some kbase builds (e.g. r49 on 6.6 kernels) expect a 56-byte atom
- * stride instead of 64. PANVK_ATOM_STRIDE overrides the default. */
+/* JM 11.19 added renderpass_id and extended v2 from 48 to 56 bytes.
+ * A 64-byte atom is v3: it has a leading seq_nr and a different layout.
+ * Do not submit a padded v2 as v3. */
static unsigned g_atom_stride;
static unsigned
@@ -123,7 +125,7 @@
if (!g_atom_stride) {
const char *e = getenv("PANVK_ATOM_STRIDE");
unsigned v = e ? (unsigned)atoi(e) : 0;
- g_atom_stride = (v == 48 || v == 56 || v == 64) ? v : 64;
+ g_atom_stride = (v == 48 || v == 56) ? v : 56;
if (e)
dprintf(2, "[FristOneRR] atom stride = %u\n", g_atom_stride);
}
@@ -1223,43 +1225,6 @@
}
-/* Probe the JOB_SUBMIT atom stride once per process: submit an empty
- * dependency-only atom (no GPU work) with stride 64. Kernels that expect
- * 56 (e.g. r49 on 6.6) answer with a config fault (0x40) or reject the
- * ioctl; then we switch to 56. PANVK_ATOM_STRIDE always wins. */
-static void
-kbase_probe_atom_stride(int fd)
-{
- if (g_atom_stride || getenv("PANVK_ATOM_STRIDE")) {
- (void)kbase_atom_stride();
- return;
- }
-
- struct base_jd_atom atom = { .atom_number = 255, .core_req = 0 };
- struct kbase_ioctl_job_submit sub = {
- .addr = (uint64_t)(uintptr_t)&atom,
- .nr_atoms = 1,
- .stride = 64,
- };
- unsigned result = 64;
-
- if (ioctl(fd, KBASE_IOCTL_JOB_SUBMIT, &sub) < 0) {
- result = 56;
- } else {
- struct pollfd pfd = { .fd = fd, .events = POLLIN };
- if (poll(&pfd, 1, 1000) > 0) {
- struct base_jd_event_v2 ev = { 0 };
- if (read(fd, &ev, sizeof(ev)) == (ssize_t)sizeof(ev) &&
- (ev.event_code & BASE_JD_EVENT_ERR_MASK))
- result = 56;
- }
- }
-
- g_atom_stride = result;
- if (result != 64)
- dprintf(2, "[FristOneRR] atom stride auto-detected: %u\n", result);
-}
-
static struct pan_kmod_dev *
kbase_kmod_dev_create(int fd, uint32_t flags,
UNUSED const struct pan_kmod_driver *drv_info,
@@ -1307,8 +1272,12 @@
return NULL;
}
- if (!is_csf)
- kbase_probe_atom_stride(fd);
+ STATIC_ASSERT(sizeof(struct base_jd_atom) == 56);
+ if (!is_csf) {
+ if (!g_atom_stride && !getenv("PANVK_ATOM_STRIDE"))
+ g_atom_stride = ver.minor < 19 ? 48 : 56;
+ (void)kbase_atom_stride();
+ }
/* Empirically, this Mali-G57 kbase JM backend needs ~2s of real
* wall-clock time after context creation before the GPU power domain
@@ -0,0 +1,26 @@
diff --git a/src/panfrost/vulkan/panvk_vX_physical_device.c b/src/panfrost/vulkan/panvk_vX_physical_device.c
--- a/src/panfrost/vulkan/panvk_vX_physical_device.c
+++ b/src/panfrost/vulkan/panvk_vX_physical_device.c
@@ -198,7 +198,9 @@
.EXT_legacy_dithering = true,
.EXT_line_rasterization = true,
.EXT_load_store_op_none = true,
- .EXT_map_memory_placed = true,
+ /* kbase SAME_VA allocations cannot be mapped at a caller-chosen
+ * address. Wine WoW64 selects that path when this extension exists. */
+ .EXT_map_memory_placed = !device->kbase_node_path[0],
.EXT_external_memory_host = true,
.EXT_nested_command_buffer = PAN_ARCH >= 10,
.EXT_memory_budget = true,
@@ -729,9 +731,9 @@
.nestedCommandBufferSimultaneousUse = PAN_ARCH >= 10,
/* VK_EXT_map_memory_placed */
- .memoryMapPlaced = true,
+ .memoryMapPlaced = !device->kbase_node_path[0],
.memoryMapRangePlaced = false,
- .memoryUnmapReserve = true,
+ .memoryUnmapReserve = !device->kbase_node_path[0],
/* VK_EXT_non_seamless_cube_map */
.nonSeamlessCubeMap = true,
+122
View File
@@ -0,0 +1,122 @@
"""Real login1 sleep handshakes; run with dbus-run-session and python3-gi."""
import os
from pathlib import Path
import subprocess
import signal
import tempfile
import time
import unittest
try:
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
except ImportError:
Gio = GLib = None
SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/bannerlator-login1"
NAME = "org.freedesktop.login1"
ROOT = "/org/freedesktop/login1"
IFACE = NAME + ".Manager"
@unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject")
class SleepHandshakeTest(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.dir = Path(self.directory.name)
self.signals = []
self.bus = Gio.DBusConnection.new_for_address_sync(
os.environ["DBUS_SESSION_BUS_ADDRESS"],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
None, None)
self.subscription = self.bus.signal_subscribe(
NAME, IFACE, "PrepareForSleep", ROOT, None, Gio.DBusSignalFlags.NONE,
lambda bus, sender, path, interface, name, body: self.signals.append(body.unpack()[0]))
env = dict(os.environ, BL_LAUNCH_DIR=str(self.dir),
DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"])
self.process = subprocess.Popen(["/usr/bin/python3", str(SCRIPT)], env=env,
stdout=subprocess.DEVNULL)
self.wait_for(lambda: (self.dir / "steam-sleep-ready").exists())
def tearDown(self):
os.kill(self.process.pid, signal.SIGCONT)
self.process.terminate()
self.process.wait(timeout=5)
self.bus.signal_unsubscribe(self.subscription)
self.bus.close_sync(None)
self.directory.cleanup()
def wait_for(self, condition, timeout=8):
deadline = time.monotonic() + timeout
while not condition():
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)
if time.monotonic() >= deadline:
self.fail("timed out waiting for login1")
time.sleep(0.01)
def call(self, interface, method, args=None):
return self.bus.call_sync(NAME, ROOT, interface, method, args, None,
Gio.DBusCallFlags.NONE, 2000, None).unpack()
def preparing(self):
return self.call("org.freedesktop.DBus.Properties", "Get",
GLib.Variant("(ss)", (IFACE, "PreparingForSleep")))[0]
def suspend(self):
self.call(IFACE, "Suspend", GLib.Variant("(b)", (True,)))
self.wait_for(lambda: (self.dir / "steam-sleep").exists())
self.wait_for(lambda: self.signals == [True])
return (self.dir / "steam-sleep").read_text().strip()
def state(self, token, state):
staged = self.dir / "steam-sleep-state.tmp"
staged.write_text(token + ":" + state + "\n")
staged.replace(self.dir / "steam-sleep-state")
def test_resume_and_second_sleep(self):
self.assertEqual(self.call(IFACE, "CanSuspend"), ("yes",))
token = self.suspend()
self.assertTrue(self.preparing())
self.state(token, "paused")
self.state("0" * 32, "awake") # stale session acknowledgments cannot wake this one
time.sleep(0.1)
self.assertTrue(self.preparing())
self.state(token, "awake")
self.wait_for(lambda: self.signals == [True, False])
self.assertFalse(self.preparing())
self.signals.clear()
second = self.suspend()
self.assertNotEqual(token, second)
self.state(second, "awake")
self.wait_for(lambda: self.signals == [True, False])
def test_missing_host_recovers(self):
self.suspend()
self.wait_for(lambda: self.signals == [True, False])
self.assertFalse(self.preparing())
self.assertFalse((self.dir / "steam-sleep").exists())
def test_wake_after_service_was_frozen_past_ack_timeout(self):
token = self.suspend()
self.state(token, "paused")
os.kill(self.process.pid, signal.SIGSTOP)
time.sleep(6)
self.state(token, "awake")
os.kill(self.process.pid, signal.SIGCONT)
self.wait_for(lambda: self.signals == [True, False])
self.assertFalse(self.preparing())
def test_failed_pause_recovers_and_duplicate_is_rejected(self):
token = self.suspend()
with self.assertRaises(GLib.Error):
self.call(IFACE, "Suspend", GLib.Variant("(b)", (True,)))
self.state(token, "awake")
self.wait_for(lambda: self.signals == [True, False])
with self.assertRaises(GLib.Error):
self.call(IFACE, "PowerOff", GLib.Variant("(b)", (True,)))
if __name__ == "__main__":
unittest.main()