mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42e8bc3f78 | ||
|
|
ffb4477514 | ||
|
|
6448352dc8 | ||
|
|
e427c087b1 | ||
|
|
b0b5f78d5d | ||
|
|
feb1fb1faa | ||
|
|
dcd2641a90 | ||
|
|
2ece5b7314 | ||
|
|
423c5388ee | ||
|
|
5f3112fb4b | ||
|
|
f3600fa618 | ||
|
|
f982ffcaf7 | ||
|
|
255c64551e | ||
|
|
746f28c1b2 | ||
|
|
2b481ba250 | ||
|
|
49622623de | ||
|
|
69f99ade42 | ||
|
|
c236e9711f | ||
|
|
4c2efb6688 | ||
|
|
7eb2d1cf1a | ||
|
|
b4ff83cf9f | ||
|
|
85a07a556c | ||
|
|
8dfc48d58b | ||
|
|
82ebdaa0b5 | ||
|
|
cbd4ce6636 | ||
|
|
d4130181e7 | ||
|
|
4e90e03536 | ||
|
|
454193da9b | ||
|
|
38d658abf5 |
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Executable
+51
@@ -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"
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user