From 7dc00d9b235c2ef0bbe55b99c5d4a2c8ff0afd82 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 20:59:45 -0400 Subject: [PATCH 01/15] Desktop GPU: patched wlroots with a Vulkan DMA-BUF allocator; new windows take the keys wlroots 0.20.2 (Arch's wlroots0.20, what the desktop's labwc links) gains an allocator that exports Vulkan images as DMA-BUFs for a GPU node that is not a DRM device - the Adreno KGSL stand-in - where gbm and the DRM lease path fail ('unable to create allocator'). linux-dmabuf skips its GEM import check on such a node so client buffers are not all refused. Built by build-wlroots.yml. Compositor: without a Wine desktop, a window that opens on top takes keyboard focus (a gamescope started from the Linux desktop). --- .github/workflows/build-wlroots.yml | 46 ++ app/src/main/cpp/waylandcomp/src/compositor.c | 4 + tools/wlroots/build-in-arch.sh | 58 ++ ...lkan-allocator-for-non-drm-gpu-nodes.patch | 549 ++++++++++++++++++ 4 files changed, 657 insertions(+) create mode 100644 .github/workflows/build-wlroots.yml create mode 100755 tools/wlroots/build-in-arch.sh create mode 100644 tools/wlroots/patches/0001-vulkan-allocator-for-non-drm-gpu-nodes.patch diff --git a/.github/workflows/build-wlroots.yml b/.github/workflows/build-wlroots.yml new file mode 100644 index 000000000..2bee0ac7b --- /dev/null +++ b/.github/workflows/build-wlroots.yml @@ -0,0 +1,46 @@ +name: Build wlroots (patched) + +# The desktop's wlroots (Arch Linux ARM's wlroots0.20, what the desktop package's labwc links) +# rebuilt with tools/wlroots/patches: a Vulkan DMA-BUF allocator for the Adreno node, which is not +# a DRM device, so labwc's vulkan and gles2 renderers can start. Built in an Arch Linux ARM +# container on an arm64 runner and published as a release asset the apk build fetches +# (tools/wlroots/release.env names the tag and sha). The hosted desktop package is never changed. +on: + push: + paths: + - 'tools/wlroots/**' + - '.github/workflows/build-wlroots.yml' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish as (e.g. wlroots-0.20.2-p1); empty = artifact only' + required: false + default: '' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - name: Build in Arch Linux ARM + run: | + set -euo pipefail + docker run --rm -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/wlroots/build-in-arch.sh + sudo chown -R "$(id -u):$(id -g)" . + - uses: actions/upload-artifact@v4 + with: + name: wlroots-patched + path: | + wlroots.tzst + wlroots.tzst.sha256 + - name: Publish + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release create "${{ inputs.tag }}" --title "${{ inputs.tag }}" --notes "wlroots 0.20.2 with tools/wlroots/patches (Vulkan DMA-BUF allocator for non-DRM GPU nodes); staged by the apk at /usr/local/lib/droiddeck-wlroots for the desktop's GPU renderers. sha256 $(cut -d' ' -f1 wlroots.tzst.sha256)" wlroots.tzst wlroots.tzst.sha256 || gh release upload "${{ inputs.tag }}" wlroots.tzst wlroots.tzst.sha256 --clobber diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index dbf4551b2..4efe9c83d 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -589,6 +589,10 @@ static void map_toplevel(struct surface *s) { s->placed ? "" : " (no desktop position yet)"); } wl_list_insert(g_toplevels.prev, &s->toplevel_link); /* new windows start on top */ + /* Without a Wine desktop the clients are whole programs (the Linux desktop, a gamescope a + * program on it started over it): a window that opens on top takes the keys too, as it would + * on any desktop, rather than the one clicked last that is now underneath it. */ + if (!g_desktop) g_key_target = s; apply_zorder(); } diff --git a/tools/wlroots/build-in-arch.sh b/tools/wlroots/build-in-arch.sh new file mode 100755 index 000000000..8d39c5dfa --- /dev/null +++ b/tools/wlroots/build-in-arch.sh @@ -0,0 +1,58 @@ +#!/usr/bin/bash +# Runs INSIDE an Arch Linux ARM container (menci/archlinuxarm:base-devel) on an arm64 runner. +# Builds Arch's own wlroots0.20 package - the wlroots the desktop package's labwc links - with the +# patches in ./patches added, keeps only the library, and packs it as wlroots.tzst +# (usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so) for the apk to stage. The desktop launcher +# puts that directory first on labwc's library path for the GPU renderers only. +set -euxo pipefail +VERSION=${WLROOTS_VERSION:-0.20.2} +WORK=/work +cd "$WORK" +# Same container workarounds as tools/gamescope/build-in-arch.sh. +grep -q '^DisableSandbox' /etc/pacman.conf || sed -i 's/^\[options\]/[options]\nDisableSandbox/' /etc/pacman.conf +{ for m in https://ca.us.mirror.archlinuxarm.org https://fl.us.mirror.archlinuxarm.org https://de3.mirror.archlinuxarm.org https://nl.mirror.archlinuxarm.org; do echo "Server = $m/\$arch/\$repo"; done; cat /etc/pacman.d/mirrorlist; } > /etc/pacman.d/mirrorlist.new +mv /etc/pacman.d/mirrorlist.new /etc/pacman.d/mirrorlist +pacman -Syu --noconfirm --needed git sudo zstd binutils +id builder >/dev/null 2>&1 || useradd -m builder +echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder +rm -rf pkg && mkdir pkg && cp -r tools/wlroots/patches pkg/ && chown -R builder pkg +sudo -u builder bash -c " +set -euxo pipefail +cd pkg +for rel in 1 2 3 4; do + if git clone -q --depth 1 --branch ${VERSION}-\$rel https://gitlab.archlinux.org/archlinux/packaging/packages/wlroots0.20.git arch 2>/dev/null; then break; fi +done +test -f arch/PKGBUILD +cd arch +grep -q '^pkgver=${VERSION}$' PKGBUILD +cp ../patches/*.patch . +PATCHES=\$(ls *.patch | sort) +{ echo; echo 'source+=('; for p in \$PATCHES; do echo \" \$p\"; done; echo ')'; for p in \$PATCHES; do echo \"sha256sums+=('SKIP')\"; done; } >> PKGBUILD +# Arch's PKGBUILD has no prepare(); ours applies the patches to the checkout. +cat >> PKGBUILD <<'PREP' + +prepare() { + cd \"\$srcdir/\$pkgname\" + for p in \$(ls \"\$srcdir\"/*.patch | sort); do + echo \"applying \$(basename \"\$p\")\" + patch -p1 --no-backup-if-mismatch < \"\$p\" + done +} +PREP +makepkg -A -s --noconfirm --skipchecksums --skippgpcheck +ls -l *.pkg.tar.* +" +cd "$WORK" +rm -rf out && mkdir -p out/usr/local/lib/droiddeck-wlroots +PKG=$(ls pkg/arch/wlroots0.20-*.pkg.tar.* | grep -v -- '-debug-' | head -1) +mkdir -p x && tar --use-compress-program=unzstd -xf "$PKG" -C x +SO=x/usr/lib/libwlroots-0.20.so +test -f "$SO" +cp -L "$SO" out/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so +strip --strip-unneeded out/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so || true +# The patch is in: the allocator's symbol and its log line. +grep -q 'Created Vulkan DMA-BUF allocator' out/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so +readelf -d out/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so | grep -E 'NEEDED|SONAME' +(cd out && tar --use-compress-program='zstd -19' -cf ../wlroots.tzst usr) +sha256sum wlroots.tzst | tee wlroots.tzst.sha256 +ls -l wlroots.tzst out/usr/local/lib/droiddeck-wlroots/ diff --git a/tools/wlroots/patches/0001-vulkan-allocator-for-non-drm-gpu-nodes.patch b/tools/wlroots/patches/0001-vulkan-allocator-for-non-drm-gpu-nodes.patch new file mode 100644 index 000000000..658233f4f --- /dev/null +++ b/tools/wlroots/patches/0001-vulkan-allocator-for-non-drm-gpu-nodes.patch @@ -0,0 +1,549 @@ +diff --git a/include/render/vulkan.h b/include/render/vulkan.h +index f96619f2..9f945f87 100644 +--- a/include/render/vulkan.h ++++ b/include/render/vulkan.h +@@ -461,6 +461,10 @@ struct wlr_vk_descriptor_pool *vulkan_alloc_blend_ds( + // Frees the given descriptor set from the pool its pool. + void vulkan_free_ds(struct wlr_vk_renderer *renderer, + struct wlr_vk_descriptor_pool *pool, VkDescriptorSet ds); ++// A DMA-BUF allocator on a Vulkan device of its own, for a DRM fd gbm cannot use (see ++// render/vulkan/allocator.c). ++struct wlr_allocator *vulkan_allocator_create(int drm_fd); ++ + struct wlr_vk_format_props *vulkan_format_props_from_drm( + struct wlr_vk_device *dev, uint32_t drm_format); + struct wlr_vk_renderer *vulkan_get_renderer(struct wlr_renderer *r); +diff --git a/render/allocator/allocator.c b/render/allocator/allocator.c +index b7dbf3d6..f3162d47 100644 +--- a/render/allocator/allocator.c ++++ b/render/allocator/allocator.c +@@ -1,6 +1,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -13,6 +14,10 @@ + #include "render/allocator/shm.h" + #include "render/wlr_renderer.h" + ++#if WLR_HAS_VULKAN_RENDERER ++#include "render/vulkan.h" ++#endif ++ + #if WLR_HAS_GBM_ALLOCATOR + #include "render/allocator/gbm.h" + #endif +@@ -32,6 +37,29 @@ void wlr_allocator_init(struct wlr_allocator *alloc, + wl_signal_init(&alloc->events.destroy); + } + ++/* Whether the fd is a DRM device at all. The GPU node on Android (KGSL, for Adreno) stands in for ++ * a render node so that Vulkan and libdrm's device lookup can name the GPU, but it answers no DRM ++ * ioctl: drmIsMaster() takes that for mastership, and gbm cannot allocate on it. */ ++static bool is_drm_device(int fd) { ++ drmVersion *version = drmGetVersion(fd); ++ if (version == NULL) { ++ return false; ++ } ++ drmFreeVersion(version); ++ return true; ++} ++ ++#if WLR_HAS_VULKAN_RENDERER ++static struct wlr_allocator *try_vulkan_allocator(int drm_fd) { ++ wlr_log(WLR_DEBUG, "Trying to create Vulkan allocator"); ++ struct wlr_allocator *alloc = vulkan_allocator_create(drm_fd); ++ if (alloc == NULL) { ++ wlr_log(WLR_DEBUG, "Failed to create Vulkan allocator"); ++ } ++ return alloc; ++} ++#endif ++ + /* Re-open the DRM node to avoid GEM handle ref'counting issues. See: + * https://gitlab.freedesktop.org/mesa/drm/-/merge_requests/110 + */ +@@ -109,8 +137,20 @@ struct wlr_allocator *wlr_allocator_autocreate(struct wlr_backend *backend, + struct wlr_allocator *alloc = NULL; + + uint32_t gbm_caps = WLR_BUFFER_CAP_DMABUF; ++ bool drm_device = drm_fd >= 0 && is_drm_device(drm_fd); ++ const char *forced = getenv("WLR_ALLOCATOR"); ++ bool vk_first = forced != NULL && strcmp(forced, "vulkan") == 0; ++#if WLR_HAS_VULKAN_RENDERER ++ // A GPU node that is not a DRM device (and WLR_ALLOCATOR=vulkan): buffers come from Vulkan. ++ if ((backend_caps & gbm_caps) && (renderer_caps & gbm_caps) && drm_fd >= 0 ++ && (vk_first || !drm_device)) { ++ if ((alloc = try_vulkan_allocator(drm_fd)) != NULL) { ++ return alloc; ++ } ++ } ++#endif + if ((backend_caps & gbm_caps) && (renderer_caps & gbm_caps) +- && drm_fd >= 0) { ++ && drm_fd >= 0 && drm_device) { + #if WLR_HAS_GBM_ALLOCATOR + wlr_log(WLR_DEBUG, "Trying to create gbm allocator"); + int gbm_fd = reopen_drm_node(drm_fd, true); +@@ -124,6 +164,11 @@ struct wlr_allocator *wlr_allocator_autocreate(struct wlr_backend *backend, + wlr_log(WLR_DEBUG, "Failed to create gbm allocator"); + #else + wlr_log(WLR_DEBUG, "Skipping gbm allocator: disabled at compile-time"); ++#endif ++#if WLR_HAS_VULKAN_RENDERER ++ if (!vk_first && (alloc = try_vulkan_allocator(drm_fd)) != NULL) { ++ return alloc; ++ } + #endif + } + +@@ -138,7 +183,7 @@ struct wlr_allocator *wlr_allocator_autocreate(struct wlr_backend *backend, + + uint32_t drm_caps = WLR_BUFFER_CAP_DMABUF | WLR_BUFFER_CAP_DATA_PTR; + if ((backend_caps & drm_caps) && (renderer_caps & drm_caps) +- && drm_fd >= 0 && drmIsMaster(drm_fd)) { ++ && drm_fd >= 0 && drm_device && drmIsMaster(drm_fd)) { + wlr_log(WLR_DEBUG, "Trying to create drm dumb allocator"); + int dumb_fd = reopen_drm_node(drm_fd, false); + if (dumb_fd < 0) { +diff --git a/render/vulkan/allocator.c b/render/vulkan/allocator.c +new file mode 100644 +index 00000000..4c00d361 +--- /dev/null ++++ b/render/vulkan/allocator.c +@@ -0,0 +1,392 @@ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include "render/vulkan.h" ++ ++/* ++ * DMA-BUFs allocated by Vulkan, for a GPU whose device node is not a DRM device and so cannot be ++ * given to gbm - Adreno through KGSL on Android, where the node Turnip opens stands in for a ++ * render node. Each buffer is an exportable image with an explicit DRM format modifier, created ++ * the way vulkan_import_dmabuf() imports one for rendering, so the renderer and the parent ++ * compositor see the same layout. The device is our own, on the physical device the DRM fd names: ++ * this works under the gles2 renderer too, as long as its EGL can import the result. ++ */ ++ ++struct wlr_vk_allocator { ++ struct wlr_allocator base; ++ struct wlr_vk_device *dev; ++ PFN_vkGetMemoryFdKHR get_memory_fd; ++ PFN_vkGetImageDrmFormatModifierPropertiesEXT get_modifier_props; ++ struct wl_list buffers; // wlr_vk_allocator_buffer.link ++}; ++ ++struct wlr_vk_allocator_buffer { ++ struct wlr_buffer base; ++ struct wlr_vk_allocator *alloc; // NULL once the allocator is gone ++ struct wl_list link; ++ VkImage image; ++ VkDeviceMemory memory; ++ struct wlr_dmabuf_attributes dmabuf; ++}; ++ ++static const struct wlr_buffer_impl buffer_impl; ++ ++static struct wlr_vk_allocator_buffer *buffer_from_buffer(struct wlr_buffer *wlr_buffer) { ++ assert(wlr_buffer->impl == &buffer_impl); ++ struct wlr_vk_allocator_buffer *buffer = wl_container_of(wlr_buffer, buffer, base); ++ return buffer; ++} ++ ++static void buffer_release_vk(struct wlr_vk_allocator_buffer *buffer) { ++ if (buffer->alloc == NULL) { ++ return; ++ } ++ VkDevice dev = buffer->alloc->dev->dev; ++ // The exported DMA-BUF keeps the memory alive for whoever imported it. ++ if (buffer->image != VK_NULL_HANDLE) { ++ vkDestroyImage(dev, buffer->image, NULL); ++ } ++ if (buffer->memory != VK_NULL_HANDLE) { ++ vkFreeMemory(dev, buffer->memory, NULL); ++ } ++ buffer->image = VK_NULL_HANDLE; ++ buffer->memory = VK_NULL_HANDLE; ++ wl_list_remove(&buffer->link); ++ buffer->alloc = NULL; ++} ++ ++static void buffer_destroy(struct wlr_buffer *wlr_buffer) { ++ struct wlr_vk_allocator_buffer *buffer = buffer_from_buffer(wlr_buffer); ++ wlr_buffer_finish(wlr_buffer); ++ buffer_release_vk(buffer); ++ wlr_dmabuf_attributes_finish(&buffer->dmabuf); ++ free(buffer); ++} ++ ++static bool buffer_get_dmabuf(struct wlr_buffer *wlr_buffer, ++ struct wlr_dmabuf_attributes *dmabuf) { ++ struct wlr_vk_allocator_buffer *buffer = buffer_from_buffer(wlr_buffer); ++ *dmabuf = buffer->dmabuf; ++ return true; ++} ++ ++static const struct wlr_buffer_impl buffer_impl = { ++ .destroy = buffer_destroy, ++ .get_dmabuf = buffer_get_dmabuf, ++}; ++ ++// One vkCreateImage over the modifiers that share the same mutable-sRGB setting, the one thing ++// in the import that depends on the modifier. The driver picks the modifier. ++static VkImage create_image(struct wlr_vk_allocator *alloc, ++ const struct wlr_vk_format_props *props, int width, int height, ++ const uint64_t *mods, uint32_t mods_len, bool mutable_srgb) { ++ VkImageCreateInfo img_info = { ++ .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, ++ .imageType = VK_IMAGE_TYPE_2D, ++ .format = props->format.vk, ++ .mipLevels = 1, ++ .arrayLayers = 1, ++ .samples = VK_SAMPLE_COUNT_1_BIT, ++ .sharingMode = VK_SHARING_MODE_EXCLUSIVE, ++ .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, ++ .extent = (VkExtent3D){ width, height, 1 }, ++ .usage = vulkan_render_usage, ++ .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT, ++ }; ++ if (mutable_srgb) { ++ img_info.flags |= VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT; ++ } ++ ++ VkExternalMemoryImageCreateInfo eimg = { ++ .sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO, ++ .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, ++ }; ++ img_info.pNext = &eimg; ++ ++ VkImageDrmFormatModifierListCreateInfoEXT mod_list = { ++ .sType = VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_LIST_CREATE_INFO_EXT, ++ .drmFormatModifierCount = mods_len, ++ .pDrmFormatModifiers = mods, ++ }; ++ eimg.pNext = &mod_list; ++ ++ VkFormat view_formats[] = { ++ props->format.vk, ++ props->format.vk_srgb, ++ }; ++ VkImageFormatListCreateInfoKHR list_info = { ++ .sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO_KHR, ++ .pViewFormats = view_formats, ++ .viewFormatCount = sizeof(view_formats) / sizeof(view_formats[0]), ++ }; ++ if (mutable_srgb) { ++ mod_list.pNext = &list_info; ++ } ++ ++ VkImage image = VK_NULL_HANDLE; ++ VkResult res = vkCreateImage(alloc->dev->dev, &img_info, NULL, &image); ++ if (res != VK_SUCCESS) { ++ wlr_vk_error("vkCreateImage", res); ++ return VK_NULL_HANDLE; ++ } ++ return image; ++} ++ ++static struct wlr_buffer *allocator_create_buffer(struct wlr_allocator *wlr_allocator, ++ int width, int height, const struct wlr_drm_format *format) { ++ struct wlr_vk_allocator *alloc = wl_container_of(wlr_allocator, alloc, base); ++ VkDevice dev = alloc->dev->dev; ++ ++ const struct wlr_vk_format_props *props = ++ vulkan_format_props_from_drm(alloc->dev, format->format); ++ if (props == NULL) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: format 0x%08"PRIX32" not supported", ++ format->format); ++ return NULL; ++ } ++ ++ // The modifiers asked for that this device can render to at this size, split by whether the ++ // import will make the image mutable-format. INVALID (implicit) is not something Vulkan can ++ // allocate; LINEAR stands in for it when it is all there is. ++ uint64_t *mutable_mods = calloc(format->len + 1, sizeof(uint64_t)); ++ uint64_t *plain_mods = calloc(format->len + 1, sizeof(uint64_t)); ++ if (mutable_mods == NULL || plain_mods == NULL) { ++ free(mutable_mods); ++ free(plain_mods); ++ return NULL; ++ } ++ uint32_t mutable_len = 0, plain_len = 0; ++ bool implicit_only = format->len > 0; ++ for (size_t i = 0; i <= format->len; i++) { ++ uint64_t mod; ++ if (i < format->len) { ++ mod = format->modifiers[i]; ++ if (mod == DRM_FORMAT_MOD_INVALID) { ++ continue; ++ } ++ implicit_only = false; ++ } else if (implicit_only) { ++ mod = DRM_FORMAT_MOD_LINEAR; ++ } else { ++ break; ++ } ++ const struct wlr_vk_format_modifier_props *mp = ++ vulkan_format_props_find_modifier(props, mod, true); ++ if (mp == NULL || (uint32_t)width > mp->max_extent.width || ++ (uint32_t)height > mp->max_extent.height) { ++ continue; ++ } ++ if (mp->has_mutable_srgb) { ++ mutable_mods[mutable_len++] = mod; ++ } else { ++ plain_mods[plain_len++] = mod; ++ } ++ } ++ ++ VkImage image = VK_NULL_HANDLE; ++ if (mutable_len > 0) { ++ image = create_image(alloc, props, width, height, mutable_mods, mutable_len, true); ++ } ++ if (image == VK_NULL_HANDLE && plain_len > 0) { ++ image = create_image(alloc, props, width, height, plain_mods, plain_len, false); ++ } ++ free(mutable_mods); ++ free(plain_mods); ++ if (image == VK_NULL_HANDLE) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: no usable modifier for %dx%d format 0x%08"PRIX32, ++ width, height, format->format); ++ return NULL; ++ } ++ ++ struct wlr_vk_allocator_buffer *buffer = calloc(1, sizeof(*buffer)); ++ if (buffer == NULL) { ++ vkDestroyImage(dev, image, NULL); ++ return NULL; ++ } ++ wlr_buffer_init(&buffer->base, &buffer_impl, width, height); ++ buffer->alloc = alloc; ++ buffer->image = image; ++ wl_list_insert(&alloc->buffers, &buffer->link); ++ for (int i = 0; i < WLR_DMABUF_MAX_PLANES; i++) { ++ buffer->dmabuf.fd[i] = -1; ++ } ++ ++ VkMemoryRequirements reqs; ++ vkGetImageMemoryRequirements(dev, image, &reqs); ++ int mem_type = vulkan_find_mem_type(alloc->dev, ++ VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, reqs.memoryTypeBits); ++ if (mem_type < 0) { ++ mem_type = vulkan_find_mem_type(alloc->dev, 0, reqs.memoryTypeBits); ++ } ++ if (mem_type < 0) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: no memory type for the image"); ++ goto error; ++ } ++ ++ VkMemoryDedicatedAllocateInfo dedicated = { ++ .sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, ++ .image = image, ++ }; ++ VkExportMemoryAllocateInfo export = { ++ .sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO, ++ .pNext = &dedicated, ++ .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, ++ }; ++ VkMemoryAllocateInfo mem_info = { ++ .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, ++ .pNext = &export, ++ .allocationSize = reqs.size, ++ .memoryTypeIndex = mem_type, ++ }; ++ VkResult res = vkAllocateMemory(dev, &mem_info, NULL, &buffer->memory); ++ if (res != VK_SUCCESS) { ++ wlr_vk_error("vkAllocateMemory", res); ++ goto error; ++ } ++ res = vkBindImageMemory(dev, image, buffer->memory, 0); ++ if (res != VK_SUCCESS) { ++ wlr_vk_error("vkBindImageMemory", res); ++ goto error; ++ } ++ ++ VkImageDrmFormatModifierPropertiesEXT mod_props = { ++ .sType = VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_PROPERTIES_EXT, ++ }; ++ res = alloc->get_modifier_props(dev, image, &mod_props); ++ if (res != VK_SUCCESS) { ++ wlr_vk_error("vkGetImageDrmFormatModifierPropertiesEXT", res); ++ goto error; ++ } ++ const struct wlr_vk_format_modifier_props *mp = ++ vulkan_format_props_find_modifier(props, mod_props.drmFormatModifier, true); ++ if (mp == NULL) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: driver chose modifier 0x%016"PRIX64 ++ " which it cannot render to", mod_props.drmFormatModifier); ++ goto error; ++ } ++ uint32_t n_planes = mp->props.drmFormatModifierPlaneCount; ++ if (n_planes == 0 || n_planes > WLR_DMABUF_MAX_PLANES) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: %"PRIu32" memory planes", n_planes); ++ goto error; ++ } ++ ++ VkMemoryGetFdInfoKHR fd_info = { ++ .sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR, ++ .memory = buffer->memory, ++ .handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, ++ }; ++ int fd = -1; ++ res = alloc->get_memory_fd(dev, &fd_info, &fd); ++ if (res != VK_SUCCESS || fd < 0) { ++ wlr_vk_error("vkGetMemoryFdKHR", res); ++ goto error; ++ } ++ ++ buffer->dmabuf = (struct wlr_dmabuf_attributes){ ++ .width = width, ++ .height = height, ++ .format = format->format, ++ .modifier = mod_props.drmFormatModifier, ++ .n_planes = n_planes, ++ }; ++ for (int i = 0; i < WLR_DMABUF_MAX_PLANES; i++) { ++ buffer->dmabuf.fd[i] = -1; ++ } ++ static const VkImageAspectFlagBits plane_aspects[] = { ++ VK_IMAGE_ASPECT_MEMORY_PLANE_0_BIT_EXT, ++ VK_IMAGE_ASPECT_MEMORY_PLANE_1_BIT_EXT, ++ VK_IMAGE_ASPECT_MEMORY_PLANE_2_BIT_EXT, ++ VK_IMAGE_ASPECT_MEMORY_PLANE_3_BIT_EXT, ++ }; ++ for (uint32_t i = 0; i < n_planes; i++) { ++ VkImageSubresource sub = { .aspectMask = plane_aspects[i] }; ++ VkSubresourceLayout layout; ++ vkGetImageSubresourceLayout(dev, image, &sub, &layout); ++ buffer->dmabuf.offset[i] = layout.offset; ++ buffer->dmabuf.stride[i] = layout.rowPitch; ++ // Every plane lives in the one allocation; each gets its own fd, as ++ // wlr_dmabuf_attributes_finish() closes them one by one. ++ buffer->dmabuf.fd[i] = i == 0 ? fd : fcntl(fd, F_DUPFD_CLOEXEC, 0); ++ if (buffer->dmabuf.fd[i] < 0) { ++ wlr_log_errno(WLR_ERROR, "Vulkan allocator: dup failed"); ++ goto error; ++ } ++ } ++ ++ wlr_log(WLR_DEBUG, "Vulkan allocator: %dx%d format 0x%08"PRIX32" modifier 0x%016"PRIX64 ++ ", %"PRIu32" plane(s)", width, height, format->format, mod_props.drmFormatModifier, n_planes); ++ return &buffer->base; ++ ++error: ++ buffer_destroy(&buffer->base); ++ return NULL; ++} ++ ++static void allocator_destroy(struct wlr_allocator *wlr_allocator) { ++ struct wlr_vk_allocator *alloc = wl_container_of(wlr_allocator, alloc, base); ++ // Buffers may outlive their allocator; their DMA-BUF fds stay valid without the device. ++ struct wlr_vk_allocator_buffer *buffer, *tmp; ++ wl_list_for_each_safe(buffer, tmp, &alloc->buffers, link) { ++ buffer_release_vk(buffer); ++ } ++ struct wlr_vk_instance *ini = alloc->dev->instance; ++ vulkan_device_destroy(alloc->dev); ++ vulkan_instance_destroy(ini); ++ free(alloc); ++} ++ ++static const struct wlr_allocator_interface allocator_impl = { ++ .destroy = allocator_destroy, ++ .create_buffer = allocator_create_buffer, ++}; ++ ++struct wlr_allocator *vulkan_allocator_create(int drm_fd) { ++ struct wlr_vk_instance *ini = vulkan_instance_create(false); ++ if (ini == NULL) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: could not create an instance"); ++ return NULL; ++ } ++ VkPhysicalDevice phdev = vulkan_find_drm_phdev(ini, drm_fd); ++ if (phdev == VK_NULL_HANDLE) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: no Vulkan device for the DRM fd"); ++ vulkan_instance_destroy(ini); ++ return NULL; ++ } ++ struct wlr_vk_device *dev = vulkan_device_create(ini, phdev); ++ if (dev == NULL) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: could not create a device"); ++ vulkan_instance_destroy(ini); ++ return NULL; ++ } ++ ++ struct wlr_vk_allocator *alloc = calloc(1, sizeof(*alloc)); ++ if (alloc == NULL) { ++ vulkan_device_destroy(dev); ++ vulkan_instance_destroy(ini); ++ return NULL; ++ } ++ alloc->dev = dev; ++ wl_list_init(&alloc->buffers); ++ alloc->get_memory_fd = (PFN_vkGetMemoryFdKHR) ++ vkGetDeviceProcAddr(dev->dev, "vkGetMemoryFdKHR"); ++ alloc->get_modifier_props = (PFN_vkGetImageDrmFormatModifierPropertiesEXT) ++ vkGetDeviceProcAddr(dev->dev, "vkGetImageDrmFormatModifierPropertiesEXT"); ++ if (alloc->get_memory_fd == NULL || alloc->get_modifier_props == NULL) { ++ wlr_log(WLR_ERROR, "Vulkan allocator: the device lacks DMA-BUF export"); ++ free(alloc); ++ vulkan_device_destroy(dev); ++ vulkan_instance_destroy(ini); ++ return NULL; ++ } ++ ++ wlr_allocator_init(&alloc->base, &allocator_impl, WLR_BUFFER_CAP_DMABUF); ++ wlr_log(WLR_INFO, "Created Vulkan DMA-BUF allocator"); ++ return &alloc->base; ++} +diff --git a/render/vulkan/meson.build b/render/vulkan/meson.build +index 36a822f9..abeb5337 100644 +--- a/render/vulkan/meson.build ++++ b/render/vulkan/meson.build +@@ -37,6 +37,7 @@ glslang_version_info = run_command(glslang, '--version', check: true).stdout() + glslang_version = glslang_version_info.split('\n')[0].split(':')[-1] + + wlr_files += files( ++ 'allocator.c', + 'pass.c', + 'renderer.c', + 'texture.c', +diff --git a/types/wlr_linux_dmabuf_v1.c b/types/wlr_linux_dmabuf_v1.c +index 3165e880..a7fe060c 100644 +--- a/types/wlr_linux_dmabuf_v1.c ++++ b/types/wlr_linux_dmabuf_v1.c +@@ -901,11 +901,22 @@ static bool set_default_feedback(struct wlr_linux_dmabuf_v1 *linux_dmabuf, + if (device->available_nodes & (1 << DRM_NODE_RENDER)) { + const char *name = device->nodes[DRM_NODE_RENDER]; + main_device_fd = open(name, O_RDWR | O_CLOEXEC); +- drmFreeDevice(&device); + if (main_device_fd < 0) { + wlr_log_errno(WLR_ERROR, "Failed to open DRM device %s", name); ++ drmFreeDevice(&device); + goto error_compiled; + } ++ // A GPU node that stands in for a render node without being a DRM device (KGSL on ++ // Android) cannot import a buffer as a GEM handle; every client's would be refused. ++ drmVersion *version = drmGetVersion(main_device_fd); ++ if (version == NULL) { ++ wlr_log(WLR_INFO, "%s is not a DRM device, skipping DMA-BUF import checks", name); ++ close(main_device_fd); ++ main_device_fd = -1; ++ } else { ++ drmFreeVersion(version); ++ } ++ drmFreeDevice(&device); + } else { + // Likely a split display/render setup. Unfortunately we have no way to + // get back the proper render node used by the renderer under-the-hood. From d984a4ba319f02cd5d6bac0a5ad8e06dcd2c5836 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:02:24 -0400 Subject: [PATCH 02/15] Desktop GPU: menu games run in their own gamescope; labwc on the patched wlroots droiddeck-gpu runs a program in a gamescope of its own on the app's compositor (wayland-0), full screen over the desktop - the path the rail's play chips use, where Vulkan works. droiddeck-desktop-gpu gives every Game/Emulator menu entry a per-user copy through it (auto: while the desktop is on pixman), keeping 'Run in a desktop window' as an action. droiddeck-desktop loads /usr/local/lib/droiddeck-wlroots for the vulkan/gles2 renderers and falls back to pixman when labwc stops at start. The build stages the wlroots release named by tools/wlroots/release.env, once published. --- .github/workflows/build.yml | 13 +- .../launcher/session/SessionFiles.kt | 9 +- .../launcher/session/SessionPrefs.kt | 7 +- .../launcher/session/SessionService.kt | 11 +- .../launcher/ui/ModeSettingsDialog.kt | 7 +- tools/linuxfs/desktop/droiddeck-desktop | 56 +++++++- tools/linuxfs/desktop/droiddeck-desktop-gpu | 123 ++++++++++++++++++ tools/linuxfs/desktop/droiddeck-gpu | 32 +++++ 8 files changed, 238 insertions(+), 20 deletions(-) create mode 100644 tools/linuxfs/desktop/droiddeck-desktop-gpu create mode 100644 tools/linuxfs/desktop/droiddeck-gpu diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4669f717..c1b215973 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -150,7 +150,7 @@ jobs: uses: actions/cache@v4 with: path: app/src/main/assets/linuxfs - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/mangoapp/*') }} + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' @@ -191,6 +191,15 @@ jobs: test -f "$D/usr/local/bin/gamescope" rm gamescope.tzst fi + # The patched wlroots for the desktop's GPU renderers (tools/wlroots), the same way. + if [ -f tools/wlroots/release.env ]; then + . tools/wlroots/release.env + gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst + echo "${WLROOTS_SHA256} wlroots.tzst" | sha256sum -c - + tar --use-compress-program=unzstd -xf wlroots.tzst -C "$D" + test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" + rm wlroots.tzst + fi # Valve's aarch64 mangoapp for Deck mode's performance overlay, plus the five libraries the # runtime lacks, pinned by sha256 in tools/mangoapp/packages.txt. Stored under the SONAME # each is loaded by, as real files: assets cannot carry symlinks. @@ -212,6 +221,8 @@ jobs: ls -l "$M" # And the desktop's launcher and labwc defaults, staged over the desktop package's copies. install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop" + install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu" + install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu" install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart" install -Dm644 tools/linuxfs/desktop/rc.xml "$D/etc/xdg/labwc/rc.xml" install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 78b09fc5e..a517693cc 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -56,6 +56,9 @@ object SessionFiles { // staging them into a runtime without it would make the desktop look present when it is not. val desktop = arrayOf( "usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop", + // Games and emulators from the menu, full screen in a gamescope of their own. + "usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu", + "usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu", "etc/xdg/labwc/autostart" to "etc/xdg/labwc/autostart", "etc/xdg/labwc/rc.xml" to "etc/xdg/labwc/rc.xml", "etc/xdg/lxqt/panel.conf" to "etc/xdg/lxqt/panel.conf", @@ -75,9 +78,13 @@ object SessionFiles { "usr/local/lib/mangoapp/libtraceevent.so.1", "usr/local/lib/mangoapp/libtracefs.so.1", ).map { it to it } + // The patched wlroots (tools/wlroots) the desktop loads for its vulkan / gles2 renderers. + val wlroots = if (File(root, "usr/bin/labwc").isFile) { + arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so") + } else emptyArray() val optional = (arrayOf( "usr/local/bin/gamescope" to "usr/local/bin/gamescope", - ) + mangoapp).filter { (asset, _) -> + ) + wlroots + mangoapp).filter { (asset, _) -> val dir = asset.substringBeforeLast('/') runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 3555e4e7c..8efbe7156 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -330,9 +330,10 @@ object SessionPrefs { } /** - * What the desktop shell composites with: pixman (software, the default - the Adreno stand-in - * is not a DRM render node, so labwc's gbm allocator cannot use it), or gles2 / vulkan for a - * device that has a real node. `Download/droiddeck-wlr-renderer` still overrides it. + * What the desktop shell composites with: pixman (software, the default), or vulkan / gles2 on + * the GPU - those need the app's patched wlroots (tools/wlroots), since the Adreno stand-in is + * not a DRM device and stock wlroots cannot allocate on it. `Download/droiddeck-wlr-renderer` + * still overrides it. */ fun desktopRenderer(context: Context): String = prefs(context).getString("desktopRenderer", "pixman") ?: "pixman" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 07241da90..d74caa21f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -407,12 +407,11 @@ class SessionService : Service() { } SessionState.fakeInputDir = fakeInputDir } - // The desktop is wlroots (labwc), and wlroots allocates its buffers through gbm on a real - // DRM render node. Ours is a KGSL stand-in that gbm cannot use - labwc dies at "unable to - // create allocator" - so the desktop shell is composited by pixman (software, a shm - // allocator, no DRM). Accelerated clients on it pay a CPU copy; a 2D emulator does not - // notice, a demanding one does. droiddeck-wlr-renderer in Downloads (pixman/vulkan/gles2) - // overrides it, for trying acceleration on a device that has a real node. + // The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render + // node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the + // default is pixman (software, shm), where a Vulkan program cannot draw at all; those run + // through droiddeck-gpu instead. vulkan / gles2 use the app's patched wlroots and fall back + // to pixman by themselves. droiddeck-wlr-renderer in Downloads overrides the choice. if (SessionState.mode == MODE_DESKTOP) { val override = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-wlr-renderer") .takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 8c3d99abb..9183c0781 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -322,8 +322,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { if (!steam && s.renderer != null) SettingsGroup("Renderer") { ChoiceRow( host, "renderer", "Desktop renderer", "Composites the desktop.", - listOf("pixman" to "pixman - software", "gles2" to "gles2", "vulkan" to "vulkan"), s.renderer, - note = "GLES2 and Vulkan require a DRM render node, unavailable on most devices.", onPick = a.onRenderer, + listOf("pixman" to "pixman - software", "vulkan" to "vulkan - GPU (experimental)", "gles2" to "gles2 - GPU (experimental)"), s.renderer, + note = "On Vulkan, programs on the desktop draw with the GPU in their own windows; if it cannot start, " + + "the desktop comes up on pixman. On pixman, games and emulators from the menu open full screen " + + "on the GPU instead (right-click one for a desktop window).", + onPick = a.onRenderer, ) } } diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 8a90ef8d1..66136d05f 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -14,9 +14,25 @@ export QT_QPA_PLATFORMTHEME=lxqt # The prefs for this are only advisory and a profile can override them; the environment is not. export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1 -# wlroots allocates through gbm on a real DRM node, which the KGSL stand-in is not: pixman -# (software, shm) is what boots here. BL_WLR_RENDERER=vulkan/gles2 tries acceleration. +# pixman composites in software and gives programs shm buffers only, so a Vulkan program on it +# fails at its swapchain. vulkan (or gles2) composite on the GPU and give programs DMA-BUFs, but +# stock wlroots allocates through gbm on a real DRM node and the KGSL stand-in is not one ("unable +# to create allocator"): they need the app's patched wlroots (tools/wlroots), whose Vulkan +# allocator exports the buffers instead. BL_WLR_RENDERER picks; see the fallback at the end. export WLR_RENDERER=${BL_WLR_RENDERER:-pixman} +WLROOTS_DIR=/usr/local/lib/droiddeck-wlroots +BASE_LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-} +use_renderer() { + export WLR_RENDERER=$1 + if [ "$1" != pixman ] && [ -f "$WLROOTS_DIR/libwlroots-0.20.so" ]; then + export LD_LIBRARY_PATH=$WLROOTS_DIR${BASE_LD_LIBRARY_PATH:+:$BASE_LD_LIBRARY_PATH} + elif [ -n "$BASE_LD_LIBRARY_PATH" ]; then + export LD_LIBRARY_PATH=$BASE_LD_LIBRARY_PATH + else + unset LD_LIBRARY_PATH + fi +} +use_renderer "$WLR_RENDERER" # The desktop's own log, beside the session's, so labwc and the programs on it can be read apart # from the session that started them. The session log keeps the milestones either way. if [ -n "${BL_DEBUG_DIR:-}" ]; then @@ -85,12 +101,38 @@ export BL_DESKTOP=1 ln -sf /usr/local/bin/bannerlator-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null ln -sf /usr/local/bin/bannerlator-steam-shim /usr/local/bin/steam 2>/dev/null python3 /usr/local/bin/bannerlator-desktop-games 2>&1 | sed 's/^/[games] /' +# Games and emulators from the menu run through droiddeck-gpu (their own gamescope on the app's +# compositor) while the desktop is on pixman, where they could not draw with the GPU. +# BL_DESKTOP_GPU=on/off overrides; each entry keeps "Run in a desktop window" either way. +gpu_entries() { python3 /usr/local/bin/droiddeck-desktop-gpu 2>&1 | sed 's/^/[gpu] /'; } +gpu_entries -echo "== droiddeck-desktop: labwc ($WLR_RENDERER) on $WAYLAND_DISPLAY" # ~/.droiddeck-desktop-debug: labwc's own debug log (views and layer surfaces mapping) too. DEBUG=; [ -f "$HOME/.droiddeck-desktop-debug" ] && DEBUG=-d -# A session bus: the panel's menus, the file manager and the portal all expect one. -if command -v dbus-run-session >/dev/null 2>&1; then - exec dbus-run-session -- labwc $DEBUG -C "$HOME/.config/labwc" +run_labwc() { + echo "== droiddeck-desktop: labwc ($WLR_RENDERER${LD_LIBRARY_PATH:+, wlroots from $WLROOTS_DIR}) on $WAYLAND_DISPLAY" + # A session bus: the panel's menus, the file manager and the portal all expect one. + if command -v dbus-run-session >/dev/null 2>&1; then + dbus-run-session -- labwc $DEBUG -C "$HOME/.config/labwc" + else + labwc $DEBUG -C "$HOME/.config/labwc" + fi +} +if [ "$WLR_RENDERER" = pixman ]; then + run_labwc + exit $? fi -exec labwc $DEBUG -C "$HOME/.config/labwc" +# A GPU renderer this device cannot start ends the desktop within a second or two; rather than a +# session that closes at once, the desktop comes up on pixman and the log says why. +started=$(date +%s) +run_labwc +rc=$? +if [ "$rc" -ne 0 ] && [ $(( $(date +%s) - started )) -lt 15 ]; then + echo "== droiddeck-desktop: labwc on $WLR_RENDERER stopped at start (exit $rc); starting on pixman instead" + use_renderer pixman + find "$XDG_RUNTIME_DIR" -mindepth 1 -maxdepth 1 ! -name 'wayland-0*' ! -name 'keymap.xkb' -exec rm -rf {} + 2>/dev/null + gpu_entries + run_labwc + rc=$? +fi +exit $rc diff --git a/tools/linuxfs/desktop/droiddeck-desktop-gpu b/tools/linuxfs/desktop/droiddeck-desktop-gpu new file mode 100644 index 000000000..08d280aec --- /dev/null +++ b/tools/linuxfs/desktop/droiddeck-desktop-gpu @@ -0,0 +1,123 @@ +#!/usr/bin/python3 +# The desktop's games and emulators, started on the GPU. Every menu entry whose categories say +# Game or Emulator gets a copy of its own in ~/.local/share/applications (which wins over +# /usr/share/applications) whose Exec runs it through droiddeck-gpu: full screen on the app's +# compositor, where Vulkan and OpenGL work. The entry keeps the old way as a second action, "Run in +# a desktop window". Run at every desktop start; copies whose original is gone, or all of them when +# the switch is off, are removed. +# +# BL_DESKTOP_GPU=auto (default: on while the desktop is composited by pixman) | on | off +import os +import re + +HOME = os.environ.get("HOME", "/root") +SYSTEM_DIR = "/usr/share/applications" +USER_DIR = os.path.join(HOME, ".local/share/applications") +MARK = "X-DroidDeck-GPU=1" +# The client and its games already hand themselves to the app (bannerlator-steam-launch). +SKIP = re.compile(r"^(steam|droiddeck-game-|droiddeck-steam|steamdeck-)", re.I) + + +def enabled(): + mode = os.environ.get("BL_DESKTOP_GPU", "auto").strip().lower() + if mode in ("on", "1", "yes", "true"): + return True + if mode in ("off", "0", "no", "false"): + return False + return os.environ.get("WLR_RENDERER", "pixman") == "pixman" + + +def main_group(text): + """The [Desktop Entry] group's lines, and everything after it.""" + lines = text.splitlines() + start = next((i for i, l in enumerate(lines) if l.strip() == "[Desktop Entry]"), None) + if start is None: + return None, None, None + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("[")), len(lines)) + return lines[:start + 1], lines[start + 1:end], lines[end:] + + +def wrap(text): + head, group, rest = main_group(text) + if group is None: + return None + keys = {} + for l in group: + if "=" in l and not l.lstrip().startswith("#"): + k, v = l.split("=", 1) + keys.setdefault(k.strip(), v) + cats = set(filter(None, keys.get("Categories", "").split(";"))) + if not cats & {"Game", "Emulator"}: + return None + if keys.get("Type", "Application") != "Application" or keys.get("NoDisplay", "").lower() == "true": + return None + exec_line = keys.get("Exec", "").strip() + if not exec_line or exec_line.startswith("droiddeck-gpu "): + return None + out_group = [] + actions = keys.get("Actions", "") + for l in group: + k = l.split("=", 1)[0].strip() if "=" in l else None + if k == "Exec": + out_group.append("Exec=droiddeck-gpu " + exec_line) + elif k == "Actions": + continue + else: + out_group.append(l) + while out_group and not out_group[-1].strip(): + out_group.pop() + out_group.append("Actions=" + ("droiddeck-window;" + actions if actions else "droiddeck-window;")) + out_group.append(MARK) + window = ["", "[Desktop Action droiddeck-window]", "Name=Run in a desktop window", "Exec=" + exec_line] + # The entry's own actions (new window, a different mode...) run on the GPU as well. + wrapped_rest = [] + for l in rest: + if l.startswith("Exec=") and not l[5:].startswith("droiddeck-gpu "): + l = "Exec=droiddeck-gpu " + l[5:] + wrapped_rest.append(l) + if wrapped_rest and wrapped_rest[0].strip(): + wrapped_rest.insert(0, "") + return "\n".join(head + out_group + window + wrapped_rest) + "\n" + + +def ours(path): + try: + with open(path, encoding="utf-8", errors="replace") as f: + return MARK in f.read() + except OSError: + return False + + +def main(): + on = enabled() + os.makedirs(USER_DIR, exist_ok=True) + written = set() + if on and os.path.isdir(SYSTEM_DIR): + for name in sorted(os.listdir(SYSTEM_DIR)): + if not name.endswith(".desktop") or SKIP.match(name): + continue + target = os.path.join(USER_DIR, name) + # A copy the user made or edited themselves is theirs. + if os.path.exists(target) and not ours(target): + continue + try: + with open(os.path.join(SYSTEM_DIR, name), encoding="utf-8", errors="replace") as f: + text = wrap(f.read()) + except OSError: + continue + if text is None: + continue + with open(target + ".tmp", "w", encoding="utf-8") as f: + f.write(text) + os.replace(target + ".tmp", target) + written.add(name) + removed = 0 + for name in os.listdir(USER_DIR): + path = os.path.join(USER_DIR, name) + if name.endswith(".desktop") and name not in written and ours(path): + os.remove(path) + removed += 1 + print(f"droiddeck-desktop-gpu: {'on' if on else 'off'}, {len(written)} entries on the GPU, {removed} removed") + + +main() diff --git a/tools/linuxfs/desktop/droiddeck-gpu b/tools/linuxfs/desktop/droiddeck-gpu new file mode 100644 index 000000000..ff6fab7f0 --- /dev/null +++ b/tools/linuxfs/desktop/droiddeck-gpu @@ -0,0 +1,32 @@ +#!/bin/bash +# Runs a program from the desktop on the GPU: in a gamescope of its own that draws straight into +# the app's compositor (wayland-0), not inside labwc. The desktop is composited by pixman, and +# pixman gives its windows no DMA-BUFs - a Vulkan program there fails at its swapchain +# (VK_ERROR_SURFACE_LOST, RPCS3's error). Here it gets the same path as a program started from the +# app's rail: full screen over the desktop until it exits, then the desktop is back. +# droiddeck-gpu [args...] +set -u +[ $# -gt 0 ] || { echo "usage: droiddeck-gpu [args...]" >&2; exit 64; } +if [ -n "${BL_DEBUG_DIR:-}" ]; then + exec >>"$BL_DEBUG_DIR/desktop.log" 2>&1 +fi +width=${BL_WIDTH:-1280} +height=${BL_HEIGHT:-720} +args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$width" -h "$height") +if [ "${BL_FPS:-0}" -gt 0 ]; then + args+=(-r "$BL_FPS") +elif [ "${BL_REFRESH:-0}" -gt 0 ]; then + args+=(-r "$BL_REFRESH") +fi +[ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled) +echo "== droiddeck-gpu $(date -u +%H:%M:%S): $* (gamescope ${width}x${height} on wayland-0)" +# gamescope is a client of the app's compositor, beside labwc; the program inside it reaches it +# through gamescope's own Xwayland, as in a Steam session. +export WAYLAND_DISPLAY=wayland-0 +export GDK_BACKEND=x11 QT_QPA_PLATFORM=xcb SDL_VIDEODRIVER=x11 +# AppImages cannot mount under proot; the menu entries already say so, a hand-typed one may not. +export APPIMAGE_EXTRACT_AND_RUN=1 +gamescope "${args[@]}" -- "$@" +rc=$? +echo "== droiddeck-gpu $(date -u +%H:%M:%S): exited $rc" +exit $rc From 5b18807f0e26bb1e75d7daa50a0ed68858d8cf73 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:04:16 -0400 Subject: [PATCH 03/15] wlroots: stage the published wlroots-0.20.2-p1 build --- tools/wlroots/release.env | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tools/wlroots/release.env diff --git a/tools/wlroots/release.env b/tools/wlroots/release.env new file mode 100644 index 000000000..f9e6fcaeb --- /dev/null +++ b/tools/wlroots/release.env @@ -0,0 +1,2 @@ +WLROOTS_TAG=wlroots-0.20.2-p1 +WLROOTS_SHA256=b4dcb08b46e69dbfaa004597844e843cdf938591ba5dbc7079b91626cdd4591d From 2220578cd137a84a7dd1644826d99769970dd44b Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:09:57 -0400 Subject: [PATCH 04/15] CI: listing the apk's assets no longer breaks the pipe past 30 entries --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1b215973..a5965ffe2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -317,7 +317,7 @@ jobs: echo "apk: $APK" ls -l "$APK" sha256sum "$APK" - unzip -l "$APK" | grep -E 'lib/arm64-v8a|assets/linuxfs' | head -30 + unzip -l "$APK" | grep -E 'lib/arm64-v8a|assets/linuxfs' | awk 'NR <= 30' # awk reads to the end: head would break the pipe under pipefail # Every NEEDED of every library we ship must resolve inside the apk or on the device. The # first build shipped libwayland-server.so without libffi.so, and the app died on From c9cfa2126e573f2291733cca9971f548202bf47f Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:24:24 -0400 Subject: [PATCH 05/15] Desktop GPU: full labwc log on the GPU renderers; a labwc that hangs at start falls back to pixman The vulkan desktop sat on the loading screen: labwc connected and never opened its window, and it did not exit, so the exit-based fallback never fired. The launcher now asks labwc's socket for an answer (wayland-info, 3 s) for up to 20 s, then stops it and starts the desktop on pixman. labwc runs with -d on vulkan/gles2 so desktop.log shows where it stopped. --- tools/linuxfs/desktop/droiddeck-desktop | 58 +++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 66136d05f..9bf138d57 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -107,8 +107,9 @@ python3 /usr/local/bin/bannerlator-desktop-games 2>&1 | sed 's/^/[games] /' gpu_entries() { python3 /usr/local/bin/droiddeck-desktop-gpu 2>&1 | sed 's/^/[gpu] /'; } gpu_entries -# ~/.droiddeck-desktop-debug: labwc's own debug log (views and layer surfaces mapping) too. -DEBUG=; [ -f "$HOME/.droiddeck-desktop-debug" ] && DEBUG=-d +# ~/.droiddeck-desktop-debug: labwc's own debug log (views and layer surfaces mapping) too. The GPU +# renderers are experimental and always log in full, so a desktop that does not come up says why. +DEBUG=; { [ -f "$HOME/.droiddeck-desktop-debug" ] || [ "$WLR_RENDERER" != pixman ]; } && DEBUG=-d run_labwc() { echo "== droiddeck-desktop: labwc ($WLR_RENDERER${LD_LIBRARY_PATH:+, wlroots from $WLROOTS_DIR}) on $WAYLAND_DISPLAY" # A session bus: the panel's menus, the file manager and the portal all expect one. @@ -118,18 +119,59 @@ run_labwc() { labwc $DEBUG -C "$HOME/.config/labwc" fi } +pids_named() { + local p + for p in /proc/[0-9]*; do + [ "$(cat "$p/comm" 2>/dev/null)" = "$1" ] && echo "${p#/proc/}" + done +} +# labwc answers on its own socket (the one beside the app's wayland-0) once its event loop runs; a +# labwc stuck on the GPU never does. +labwc_answers() { + local sock + for sock in "$XDG_RUNTIME_DIR"/wayland-*; do + case $sock in *.lock|*/wayland-0) continue ;; esac + [ -S "$sock" ] || continue + WAYLAND_DISPLAY=${sock##*/} timeout 3 wayland-info >/dev/null 2>&1 && return 0 + done + return 1 +} if [ "$WLR_RENDERER" = pixman ]; then run_labwc exit $? fi -# A GPU renderer this device cannot start ends the desktop within a second or two; rather than a -# session that closes at once, the desktop comes up on pixman and the log says why. +# A GPU renderer this device cannot start either ends the desktop at once or leaves it hanging on +# the loading screen. Either way the desktop comes up on pixman instead, and the log says why. started=$(date +%s) -run_labwc -rc=$? -if [ "$rc" -ne 0 ] && [ $(( $(date +%s) - started )) -lt 15 ]; then - echo "== droiddeck-desktop: labwc on $WLR_RENDERER stopped at start (exit $rc); starting on pixman instead" +run_labwc & +labwc_job=$! +answered= +while kill -0 "$labwc_job" 2>/dev/null; do + if labwc_answers; then answered=1; break; fi + [ $(( $(date +%s) - started )) -ge 20 ] && break + sleep 1 +done +if [ -n "$answered" ]; then + echo "== droiddeck-desktop: labwc on $WLR_RENDERER is up after $(( $(date +%s) - started )) s" + wait "$labwc_job" + exit $? +fi +if kill -0 "$labwc_job" 2>/dev/null; then + echo "== droiddeck-desktop: labwc on $WLR_RENDERER did not answer in 20 s; stopping it" + kill -TERM $(pids_named labwc) 2>/dev/null + sleep 2 + kill -KILL $(pids_named labwc) 2>/dev/null + for prog in lxqt-panel pcmanfm-qt swaybg Xwayland; do kill -KILL $(pids_named "$prog") 2>/dev/null; done + wait "$labwc_job" 2>/dev/null + rc=124 +else + wait "$labwc_job" + rc=$? +fi +if [ "$rc" -ne 0 ] && [ $(( $(date +%s) - started )) -lt 40 ]; then + echo "== droiddeck-desktop: labwc on $WLR_RENDERER failed at start (exit $rc); starting on pixman instead" use_renderer pixman + DEBUG=; [ -f "$HOME/.droiddeck-desktop-debug" ] && DEBUG=-d find "$XDG_RUNTIME_DIR" -mindepth 1 -maxdepth 1 ! -name 'wayland-0*' ! -name 'keymap.xkb' -exec rm -rf {} + 2>/dev/null gpu_entries run_labwc From 2df3d054e8412ace87baf3ed0de2543a85b46e9f Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:36:08 -0400 Subject: [PATCH 06/15] Compositor: answer a toplevel's initial commit with configure xdg-shell has the compositor reply to the initial commit; we only sent one at get_toplevel. wlroots' Wayland backend (the desktop's labwc) moves its toplevel to a private queue after the initial commit and waits there; on the vulkan renderer a buffer import in between read the early configure onto the default queue, and labwc waited for ever (WAYLAND_DEBUG: commit, then nothing). The early configure stays for Wine's driver. --- app/src/main/cpp/waylandcomp/src/compositor.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 4efe9c83d..65471e78a 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -275,6 +275,7 @@ struct surface { int sub_x, sub_y, sub_pending_x, sub_pending_y, sub_pending; int below_parent; int fullscreen; /* xdg_toplevel.set_fullscreen: no client decorations */ + int toplevel_committed; /* the toplevel's initial commit was answered */ char *title; /* xdg_toplevel title, for the session log */ int announced_vulkan; /* logged its first dmabuf frame */ @@ -892,6 +893,15 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) { struct surface *s = wl_resource_get_user_data(r); struct surface *child; + /* xdg-shell: the initial commit of a toplevel is answered with a configure. One is also sent + * at get_toplevel, which Wine's driver has always had; a client that waits for the reply to its + * commit on a queue of its own (wlroots' Wayland backend - the desktop's labwc) can read that + * early one onto the wrong queue and would wait for ever. */ + if (s->xdg_toplevel && !s->toplevel_committed) { + s->toplevel_committed = 1; + send_toplevel_configure(s); + } + if (s->pending_src_set) { s->src_set = s->pending_src[2] > 0; memcpy(s->src, s->pending_src, sizeof(s->src)); @@ -1302,6 +1312,7 @@ static void xdg_toplevel_resource_destroy(struct wl_resource *r) { struct surface *s = wl_resource_get_user_data(r); if (!s) return; s->xdg_toplevel = NULL; + s->toplevel_committed = 0; unmap_toplevel(s); s->placed = 0; s->hwnd = 0; From 66dafa602197e8bcd85c9d9de87af11cbf7122f2 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:37:16 -0400 Subject: [PATCH 07/15] Desktop: remote runs from ~/.cache/droiddeck/run behind ~/.droiddeck-remote-run For driving the desktop from a debugger: a script dropped there runs once inside the session. Off unless the marker exists. --- tools/linuxfs/desktop/autostart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/linuxfs/desktop/autostart b/tools/linuxfs/desktop/autostart index e66666a0a..3aa88e906 100644 --- a/tools/linuxfs/desktop/autostart +++ b/tools/linuxfs/desktop/autostart @@ -36,3 +36,18 @@ swaybg -m fill -i /usr/share/backgrounds/steamdeck.png & run pcmanfm-qt pcmanfm-qt --desktop --profile lxqt run lxqt-panel lxqt-panel /usr/lib/xdg-desktop-portal-wlr >/dev/null 2>&1 & +# Remote runs, for driving the desktop from a debugger: with ~/.droiddeck-remote-run present (only +# the app or root can create it), each script dropped into ~/.cache/droiddeck/run/ is run once, +# inside the session with its environment, its output tagged into the desktop log. +if [ -f "$HOME/.droiddeck-remote-run" ]; then + mkdir -p "$HOME/.cache/droiddeck/run" + ( + while sleep 1; do + for f in "$HOME"/.cache/droiddeck/run/*.sh; do + [ -f "$f" ] || continue + mv -f "$f" "$f.running" || continue + ( echo "[run] $(basename "$f") started"; bash "$f.running" 2>&1 | sed "s/^/[run] /"; echo "[run] $(basename "$f") exited"; rm -f "$f.running" ) & + done + done + ) & +fi From 2d9e1c6d95575e6127d41079c935ee6eeb21cd6a Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:41:23 -0400 Subject: [PATCH 08/15] Compositor: declare send_toplevel_configure before surface_commit --- app/src/main/cpp/waylandcomp/src/compositor.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 65471e78a..36b500eae 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -889,6 +889,8 @@ static void surface_set_opaque(struct wl_client *c, struct wl_resource *r, static void surface_set_input(struct wl_client *c, struct wl_resource *r, struct wl_resource *region) {} +static void send_toplevel_configure(struct surface *s); + static void surface_commit(struct wl_client *c, struct wl_resource *r) { struct surface *s = wl_resource_get_user_data(r); struct surface *child; From 7f2d2ffa4a321b88c7875659c5c72bcfa9b67359 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 21:52:47 -0400 Subject: [PATCH 09/15] Session: on-screen PC keyboard (Hardware) beside the Android keyboard The drawer's Keyboard row has two buttons: Hardware opens ui/PcKeyboard, a full PC layout - Esc, F1-F12, Ins/Del/Home/End/PgUp/PgDn, the number and letter rows, Tab, Caps, both Shifts, Ctrl, Super, Alt, AltGr, Space and the arrows - whose keys are evdev codes pressed and released on the compositor's wl_keyboard, as a USB keyboard would; Android opens the Android keyboard as before. Keys go down on touch and up on lift (the program's key repeat works); modifiers are sticky (tap: next key, tap again: lock, again: off). A controller moves over the keys with the d-pad and presses with A; B closes it. --- .../com/droiddeck/launcher/SessionActivity.kt | 19 +- .../com/droiddeck/launcher/ui/PcKeyboard.kt | 249 ++++++++++++++++++ .../droiddeck/launcher/ui/SessionOverlay.kt | 11 +- 3 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index a3ed30bff..6c83cddba 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -88,6 +88,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Compose reads these; the activity writes them. private var drawerOpen by mutableStateOf(false) + /** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */ + private var pcKeyboardOpen by mutableStateOf(false) private var hudOn by mutableStateOf(true) private var frameGenLabel by mutableStateOf("Off") private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF) @@ -181,6 +183,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended) // Opening the drawer takes the controller away from the game: release its pad. androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) padBridge?.releaseAll() } + // The PC keyboard takes it too, for moving over the keys; the drawer opens over it. + androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() } + if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard( + sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) }, + onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, + onClose = { pcKeyboardOpen = false }, + ) SessionDrawer(drawerOpen, DrawerActions( steam = SessionState.mode == SessionService.MODE_STEAM, isHomeApp = isHomeApp, @@ -200,7 +209,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { readPrefs() applyFrameGen() }, - onKeyboard = { drawerOpen = false; keyboard?.toggle() }, + onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, + onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ drawerOpen = false @@ -247,7 +257,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // from a button a game might also be reading. onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - drawerOpen = !drawerOpen + // The PC keyboard closes first (B on a controller), then Back is the drawer again. + if (pcKeyboardOpen && !drawerOpen) pcKeyboardOpen = false else drawerOpen = !drawerOpen } }) watchSession() @@ -612,7 +623,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // With the drawer open the controller drives the drawer, not the game: Android's own // handling moves focus with the d-pad and stick, and its fallbacks make A select and B Back // (which closes the drawer) - the same as on the app's main screen. - if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event) + if ((drawerOpen || pcKeyboardOpen) && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event) if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true // A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the // activity, which opens the drawer. @@ -654,7 +665,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { - if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event) + if ((drawerOpen || pcKeyboardOpen) && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event) if (padBridge?.onMotionEvent(event) == true) return true if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true return super.dispatchGenericMotionEvent(event) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt new file mode 100644 index 000000000..687b470bd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -0,0 +1,249 @@ +package com.droiddeck.launcher.ui + +import android.view.KeyEvent +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A PC keyboard on screen: every key is a physical key (a Linux evdev code) pressed and released + * on the session's wl_keyboard, as a USB keyboard would - Esc, the function row, Ctrl, Alt, Super, + * the arrows and the editing block included, none of which the Android keyboard can send. + * + * A key goes down when a finger lands on it and up when it lifts, so holding one repeats it the way + * the program's own key repeat does. Ctrl, Shift, Alt and Super are sticky: one tap holds the + * modifier for the next key, a second tap locks it, a third lets it go. A controller moves over the + * keys with the d-pad and presses with A. + */ +private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null) + +private fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted) + +private val ROWS: List> = listOf( + listOf( + k("Esc", 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64), + k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88), + k("Ins", 110), k("Del", 111), k("Home", 102), k("End", 107), k("PgUp", 104), k("PgDn", 109), + ), + listOf( + k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"), + k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"), + k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"), + k("=", 13, shifted = "+"), k("⌫ Backspace", 14, 2f), + ), + listOf( + k("Tab ⇥", 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21), + k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"), + k("\\", 43, 1.5f, shifted = "|"), + ), + listOf( + k("Caps", 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35), + k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""), + k("Enter ⏎", 28, 2.25f), + ), + listOf( + k("⇧ Shift", 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49), + k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"), + k("⇧ Shift", 54, 1.75f), k("↑", 103), + ), + listOf( + k("Ctrl", 29, 1.5f), k("Super", 125, 1.25f), k("Alt", 56, 1.25f), k("Space", 57, 6.25f), + k("AltGr", 100, 1.25f), k("Ctrl", 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106), + ), +) + +private val MODIFIERS = setOf(42, 54, 29, 97, 56, 100, 125) +private const val MOD_OFF = 0 +private const val MOD_ONCE = 1 +private const val MOD_LOCKED = 2 + +@Composable +fun PcKeyboard( + /** evdev code, pressed. */ + sendKey: (Int, Boolean) -> Unit, + onAndroidKeyboard: () -> Unit, + onClose: () -> Unit, +) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val mods = remember { mutableStateMapOf() } + var capsOn by remember { mutableStateOf(false) } + val firstKey = remember { FocusRequester() } + val shifted = (mods[42] ?: MOD_OFF) != MOD_OFF || (mods[54] ?: MOD_OFF) != MOD_OFF + + fun releaseOnceModifiers() { + for ((code, state) in mods.toMap()) if (state == MOD_ONCE) { sendKey(code, false); mods[code] = MOD_OFF } + } + fun press(key: PcKey) { + if (key.code in MODIFIERS) { + when (mods[key.code] ?: MOD_OFF) { + MOD_OFF -> { sendKey(key.code, true); mods[key.code] = MOD_ONCE } + MOD_ONCE -> mods[key.code] = MOD_LOCKED + else -> { sendKey(key.code, false); mods[key.code] = MOD_OFF } + } + return + } + sendKey(key.code, true) + } + fun release(key: PcKey) { + if (key.code in MODIFIERS) return + sendKey(key.code, false) + if (key.code == 58) capsOn = !capsOn + releaseOnceModifiers() + } + // Nothing is left held when the keyboard goes away. + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { for ((code, state) in mods.toMap()) if (state != MOD_OFF) sendKey(code, false) } + } + LaunchedEffect(Unit) { runCatching { firstKey.requestFocus() } } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + BoxWithConstraints( + Modifier.fillMaxWidth() + .background(Color(0xE6101418), RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) + .border(1.dp, pal.line2, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) + // Touches on the keyboard stop here; the program underneath never sees them. + .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume(); waitForUpOrCancellation()?.consume() } } + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + val rowHeight = (maxWidth / 17f).coerceIn(34.dp, 56.dp) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(28.dp)) { + Text("PC keyboard", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant) + Spacer(Modifier.weight(1f)) + HeaderButton("Android keyboard", onAndroidKeyboard) + Spacer(Modifier.width(8.dp)) + HeaderButton("Hide ✕", onClose) + } + ROWS.forEachIndexed { r, row -> + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().height(rowHeight)) { + row.forEachIndexed { i, key -> + val state = mods[key.code] ?: MOD_OFF + val lit = state != MOD_OFF || (key.code == 58 && capsOn) + val label = when { + shifted && key.shifted != null -> key.shifted + key.label.length == 1 && key.label[0].isLetter() && (shifted xor capsOn) -> key.label.uppercase() + else -> key.label + } + KeyCap( + label, key.weight, lit, locked = state == MOD_LOCKED, + modifier = if (r == 0 && i == 0) Modifier.focusRequester(firstKey) else Modifier, + onDown = { press(key) }, onUp = { release(key) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun RowScope.KeyCap( + label: String, weight: Float, lit: Boolean, locked: Boolean, + modifier: Modifier, onDown: () -> Unit, onUp: () -> Unit, +) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val focused by src.collectIsFocusedAsState() + var down by remember { mutableStateOf(false) } + val fill by animateColorAsState( + when { + down -> pal.signal.copy(alpha = 0.55f) + lit -> pal.signal.copy(alpha = if (locked) 0.45f else 0.28f) + else -> Color.White.copy(alpha = 0.07f) + }, label = "keyFill", + ) + val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.weight(weight).fillMaxHeight() + .background(fill, RoundedCornerShape(7.dp)) + .border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp)) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown().consume() + down = true; onDown() + waitForUpOrCancellation()?.consume() + down = false; onUp() + } + } + // A controller: A (or the d-pad's centre) is the finger. + .onPreviewKeyEvent { e -> + val kc = e.key.nativeKeyCode + if (kc != KeyEvent.KEYCODE_BUTTON_A && kc != KeyEvent.KEYCODE_DPAD_CENTER && kc != KeyEvent.KEYCODE_ENTER) return@onPreviewKeyEvent false + when (e.type) { + KeyEventType.KeyDown -> if (!down) { down = true; onDown() } + KeyEventType.KeyUp -> if (down) { down = false; onUp() } + } + true + } + .focusable(interactionSource = src), + ) { + Text( + label, fontSize = if (label.length > 2) 12.sp else 16.sp, fontWeight = FontWeight.SemiBold, + color = Color.White, textAlign = TextAlign.Center, maxLines = 1, + ) + } +} + +@Composable +private fun HeaderButton(text: String, onClick: () -> Unit) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val focused by src.collectIsFocusedAsState() + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxHeight() + .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) + .border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp)) + .clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) + .padding(horizontal = 12.dp), + ) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 0a568a140..0aaf1fc7d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -158,7 +158,10 @@ class DrawerActions( val selectedSecondScreenDisplay: Int, val onHud: (Boolean) -> Unit, val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, + /** The Android keyboard (text, turned into key presses). */ val onKeyboard: () -> Unit, + /** The on-screen PC keyboard: real keys, Esc, F1-F12, Ctrl, Alt... */ + val onHardwareKeyboard: () -> Unit, val onSteamMenu: (() -> Unit)?, val onQam: (() -> Unit)?, val onOsc: (String) -> Unit, @@ -320,7 +323,13 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never"), a.oscMode, onPick = a.onOsc, ) - ActionRow("Keyboard", null, "Show") { host.open = null; a.onKeyboard() } + SettingsRow("Keyboard", null) { + Row(verticalAlignment = Alignment.CenterVertically) { + SecondaryButton("Hardware") { host.open = null; a.onHardwareKeyboard() } + Spacer(Modifier.width(8.dp)) + SecondaryButton("Android") { host.open = null; a.onKeyboard() } + } + } } if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") { From 921f156128dd569937ea0c34848978883383b95b Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 22:06:47 -0400 Subject: [PATCH 10/15] Desktop programs: controller seen by every emulator, Player 1 profiles, big-core pinning - The pad keeps Steam Input's virtual-gamepad identity only in Steam sessions. SDL ignores a Steam virtual gamepad unless run under Steam, so on the desktop and from the rail every SDL emulator saw no controller at all; there it is a plain Xbox 360 pad now, which SDL and evdev map by themselves. - bannerlator-pad-defaults writes Player 1 on the controller for emulators that have no profile yet (RPCS3 first: without one it takes the keyboard). Run at desktop start and before a program from the rail; never overwrites. - The desktop and everything started from it, and a program from the rail, run on the game cores when chosen, else every core but the slowest cluster (program_cores); BL_PIN_CORES=0 turns it off. Game cores reach every mode. --- .../launcher/session/SessionFiles.kt | 1 + .../launcher/session/SessionService.kt | 18 +++-- tools/linuxfs/desktop/droiddeck-desktop | 2 + .../usr/local/bin/bannerlator-pad-defaults | 77 +++++++++++++++++++ .../overlay/usr/local/bin/bannerlator-session | 34 ++++++++ 5 files changed, 125 insertions(+), 7 deletions(-) create mode 100755 tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index a517693cc..37d937a48 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -38,6 +38,7 @@ object SessionFiles { "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", "usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts", + "usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults", // The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus // the three under /usr/bin, all no-ops that answer "nothing to do" (see each file). "usr/bin/steamos-update" to "usr/bin/steamos-update", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index d74caa21f..0cfc3dce5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -321,13 +321,13 @@ class SessionService : Service() { // on, even naming every core: it exists to undo the pin Steam applies to its own interface // renderer, and the scheduler's default is exactly what that pin takes away. A game's is // sent only when it is a real restriction - a game has no pin of its own to undo. - if (SessionState.mode == MODE_STEAM) { - if (SessionPrefs.clientCpusOverride(this)) { - guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) - } - CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this)) - .takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") } + if (SessionState.mode == MODE_STEAM && SessionPrefs.clientCpusOverride(this)) { + guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) } + // The game cores also pin the desktop and a program from the rail; without a choice the + // session script picks every core but the slowest cluster for those (program_cores). + CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this)) + .takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") } // PulseAudio always: the client is a native Linux program and has no other way to make a // sound - its menus, its music and its voice chat all go through here. DirectAudio is not @@ -398,7 +398,11 @@ class SessionService : Service() { // identity gets the standard layout without the user configuring the pad by hand. guest.add("FAKE_EVDEV_IDENTITY=xbox360") guest.add("FAKE_EVDEV_VIBRATION=1") - guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") + // Steam Input's virtual-gamepad identity is for games the client starts, which are + // meant to see that pad. Everywhere else (the desktop, a program from the rail) it + // hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so + // every SDL emulator came up with no controller. There it is a plain Xbox 360 pad. + if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") guest.add("SDL_JOYSTICK_DISABLE_UDEV=1") guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1") guest.add("SDL_JOYSTICK_HIDAPI=0") diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 9bf138d57..d371980b9 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -101,6 +101,8 @@ export BL_DESKTOP=1 ln -sf /usr/local/bin/bannerlator-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null ln -sf /usr/local/bin/bannerlator-steam-shim /usr/local/bin/steam 2>/dev/null python3 /usr/local/bin/bannerlator-desktop-games 2>&1 | sed 's/^/[games] /' +# Player 1 on the controller in every emulator that has no profile of its own yet. +python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 | sed 's/^/[pads] /' # Games and emulators from the menu run through droiddeck-gpu (their own gamescope on the app's # compositor) while the desktop is on pixman, where they could not draw with the GPU. # BL_DESKTOP_GPU=on/off overrides; each entry keeps "Run in a desktop window" either way. diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults new file mode 100755 index 000000000..82370a7de --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -0,0 +1,77 @@ +#!/usr/bin/python3 +# Player 1 on the session's controller, for every emulator that would otherwise start on the +# keyboard. The session's pad is an Xbox 360 controller (libfakeinput's identity outside Steam) that +# SDL maps by itself; what an emulator lacks is a profile that points Player 1 at it. One is +# written only where the emulator has none yet - a profile the user made is never touched. +# Run at every desktop start and before every program from the rail. +import os + +HOME = os.environ.get("HOME", "/root") +CONFIG = os.environ.get("XDG_CONFIG_HOME", os.path.join(HOME, ".config")) + + +def seed(path, text, what): + if os.path.exists(path): + return False + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write(text) + os.replace(path + ".tmp", path) + print(f"bannerlator-pad-defaults: {what}: Player 1 on the controller ({path})") + return True + + +# RPCS3: its global input configuration (rpcs3::utils::get_input_config_dir() + "Default.yml"). +# Without one it adds the keyboard pad handler (pad_config.cpp, cfg_input::load). SDL handler +# names from rpcs3/Input/sdl_pad_handler.cpp (button_list, init_config); the device is +# " " (sdl_pad_handler::enumerate_devices). +RPCS3_PLAYER1 = """Player 1 Input: + Handler: SDL + Device: Xbox 360 Controller 1 + Config: + Left Stick Left: LS X- + Left Stick Down: LS Y- + Left Stick Right: LS X+ + Left Stick Up: LS Y+ + Right Stick Left: RS X- + Right Stick Down: RS Y- + Right Stick Right: RS X+ + Right Stick Up: RS Y+ + Start: Start + Select: Back + PS Button: Guide + Square: West + Cross: South + Circle: East + Triangle: North + Left: Left + Down: Down + Right: Right + Up: Up + R1: RB + R2: RT + R3: RS + L1: LB + L2: LT + L3: LS + Left Stick Deadzone: 8000 + Right Stick Deadzone: 8000 + Left Stick Anti-Deadzone: 4259 + Right Stick Anti-Deadzone: 4259 + Left Trigger Threshold: 0 + Right Trigger Threshold: 0 + Color Value B: 20 + LED battery indicator brightness: 10 + Buddy Device: "" +""" + + +def rpcs3(): + seed(os.path.join(CONFIG, "rpcs3/input_configs/global/Default.yml"), RPCS3_PLAYER1, "RPCS3") + + +for step in (rpcs3,): + try: + step() + except OSError as e: + print(f"bannerlator-pad-defaults: {step.__name__}: {e}") diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index d4e609453..0bc8477cd 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -79,6 +79,28 @@ export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache} mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" +# The cores a program runs on outside Steam (the desktop and everything started from it, a +# program from the rail): the user's game cores when set (BL_GAME_CPUS), else every core but the +# slowest cluster - Android is free to park an emulator's threads on the little cores, and those +# are what compile shaders and recompile PPU/SPU code. BL_PIN_CORES=0 leaves the scheduler alone. +program_cores() { + [ "${BL_PIN_CORES:-1}" = 0 ] && return 0 + command -v taskset >/dev/null 2>&1 || return 0 + if [ -n "${BL_GAME_CPUS:-}" ]; then echo "$BL_GAME_CPUS"; return 0; fi + local f n max min= list= + for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do + max=$(cat "$f" 2>/dev/null) || continue + [ -z "$min" ] || [ "$max" -lt "$min" ] && min=$max + done + [ -n "$min" ] || return 0 + for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do + max=$(cat "$f" 2>/dev/null) || continue + n=${f#/sys/devices/system/cpu/cpu}; n=${n%%/*} + [ "$max" -gt "$min" ] && list="$list${list:+,}$n" + done + echo "$list" +} + if [ -z "${BL_INSIDE:-}" ]; then # The compositor may still be starting, and a socket left by an earlier session is not # a compositor: probe until a connection succeeds. @@ -91,6 +113,11 @@ if [ -z "${BL_INSIDE:-}" ]; then if [ "$mode" = lxqt ]; then step "starting the desktop" [ -x /usr/local/bin/droiddeck-desktop ] || { echo "== the desktop package is not installed"; exit 66; } + cores=$(program_cores) + if [ -n "$cores" ]; then + echo "== desktop cores: $cores (the desktop and every program started from it)" + exec taskset -c "$cores" /usr/local/bin/droiddeck-desktop + fi exec /usr/local/bin/droiddeck-desktop fi width=${BL_WIDTH:-1280} @@ -162,6 +189,13 @@ case $mode in run) target=${1:?program path} shift + cores=$(program_cores) + if [ -n "$cores" ]; then + echo "== program cores: $cores" + taskset -cp "$cores" $$ >/dev/null 2>&1 || true + fi + # Player 1 on the controller in an emulator that has no profile of its own yet. + python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 || true case ${target,,} in *.appimage) export APPIMAGE_EXTRACT_AND_RUN=1 From 79e7be470c435ca90cbc3506d0baac00b75a2d26 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 22:13:59 -0400 Subject: [PATCH 11/15] PC keyboard: shorter rows, so the program stays visible above it --- app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index 687b470bd..34a67980b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -152,7 +152,8 @@ fun PcKeyboard( .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume(); waitForUpOrCancellation()?.consume() } } .padding(horizontal = 8.dp, vertical = 6.dp), ) { - val rowHeight = (maxWidth / 17f).coerceIn(34.dp, 56.dp) + // About half the screen on a phone-shaped panel: the program stays visible above it. + val rowHeight = (maxWidth / 26f).coerceIn(30.dp, 44.dp) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(28.dp)) { Text("PC keyboard", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant) From 793180ce121b7794227fd40fa24009a4b30838fb Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 22:20:04 -0400 Subject: [PATCH 12/15] Controller profiles for every desktop emulator; PC keyboard under half the screen bannerlator-pad-defaults now covers DuckStation, PCSX2, Dolphin (GameCube and Wii), Cemu, melonDS, PPSSPP and RetroArch as well as RPCS3, from each project's source: files an emulator would otherwise fill with keyboard-only defaults are seeded when missing (Dolphin, Cemu, PPSSPP, RPCS3), or given SDL bindings in the config the emulator wrote itself when nothing binds a controller yet (DuckStation, PCSX2 with its SettingsVersion, melonDS). RetroArch gets an Xbox 360 profile for its sdl2 driver and is moved off udev, which finds no pads in the runtime. The desktop re-runs it every 30 s, so an emulator's first config is covered for its next start. The PC keyboard takes 47% of the screen height, header included. --- .../com/droiddeck/launcher/ui/PcKeyboard.kt | 19 +- tools/linuxfs/desktop/droiddeck-desktop | 5 +- .../usr/local/bin/bannerlator-pad-defaults | 353 +++++++++++++++++- 3 files changed, 365 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index 34a67980b..ad3b34405 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -143,19 +143,20 @@ fun PcKeyboard( } LaunchedEffect(Unit) { runCatching { firstKey.requestFocus() } } - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { - BoxWithConstraints( + BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + // Slightly under half the screen, header included, so the program stays in view above it: + // six rows share what is left of 47% after the header, the gaps and the padding. + val rowHeight = ((maxHeight * 0.47f - 24.dp - 3.dp * 6 - 10.dp) / 6).coerceIn(24.dp, 44.dp) + Box( Modifier.fillMaxWidth() .background(Color(0xE6101418), RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) .border(1.dp, pal.line2, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) // Touches on the keyboard stop here; the program underneath never sees them. .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume(); waitForUpOrCancellation()?.consume() } } - .padding(horizontal = 8.dp, vertical = 6.dp), + .padding(horizontal = 8.dp, vertical = 5.dp), ) { - // About half the screen on a phone-shaped panel: the program stays visible above it. - val rowHeight = (maxWidth / 26f).coerceIn(30.dp, 44.dp) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(28.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(24.dp)) { Text("PC keyboard", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant) Spacer(Modifier.weight(1f)) HeaderButton("Android keyboard", onAndroidKeyboard) @@ -163,7 +164,7 @@ fun PcKeyboard( HeaderButton("Hide ✕", onClose) } ROWS.forEachIndexed { r, row -> - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().height(rowHeight)) { + Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth().height(rowHeight)) { row.forEachIndexed { i, key -> val state = mods[key.code] ?: MOD_OFF val lit = state != MOD_OFF || (key.code == 58 && capsOn) @@ -228,7 +229,7 @@ private fun RowScope.KeyCap( .focusable(interactionSource = src), ) { Text( - label, fontSize = if (label.length > 2) 12.sp else 16.sp, fontWeight = FontWeight.SemiBold, + label, fontSize = if (label.length > 2) 11.sp else 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, textAlign = TextAlign.Center, maxLines = 1, ) } diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index d371980b9..1f4c018b4 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -101,8 +101,11 @@ export BL_DESKTOP=1 ln -sf /usr/local/bin/bannerlator-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null ln -sf /usr/local/bin/bannerlator-steam-shim /usr/local/bin/steam 2>/dev/null python3 /usr/local/bin/bannerlator-desktop-games 2>&1 | sed 's/^/[games] /' -# Player 1 on the controller in every emulator that has no profile of its own yet. +# Player 1 on the controller in every emulator that has no profile of its own yet - now, and again +# every half minute: an emulator started for the first time writes its own config, which is then +# given the controller for its next start without waiting for the next desktop. python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 | sed 's/^/[pads] /' +( while sleep 30; do python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 | sed 's/^/[pads] /'; done ) & # Games and emulators from the menu run through droiddeck-gpu (their own gamescope on the app's # compositor) while the desktop is on pixman, where they could not draw with the GPU. # BL_DESKTOP_GPU=on/off overrides; each entry keeps "Run in a desktop window" either way. diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 82370a7de..2c016d100 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -70,8 +70,357 @@ def rpcs3(): seed(os.path.join(CONFIG, "rpcs3/input_configs/global/Default.yml"), RPCS3_PLAYER1, "RPCS3") -for step in (rpcs3,): +# ---- ini helpers (DuckStation and PCSX2 share one ini dialect, keys may repeat) ------------- + +def ini_section(lines, name): + """(start, end) line indices of [name]'s body, or None.""" + start = None + for i, l in enumerate(lines): + t = l.strip() + if t.startswith("[") and t.endswith("]"): + if start is not None: + return start, i + if t[1:-1].strip() == name: + start = i + 1 + return (start, len(lines)) if start is not None else None + + +def patch_ini_section(path, section, body, what, needle="SDL-", require=None): + """Add body's lines to [section] of an ini the emulator wrote itself, unless the section + already binds a controller (needle). Missing file: nothing - the emulator writes its own + defaults on first run and this runs again.""" + if not os.path.isfile(path): + return + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + if require and require not in text: + return + lines = text.splitlines() + span = ini_section(lines, section) + if span and any(needle in l for l in lines[span[0]:span[1]]): + return + new = body.strip("\n").splitlines() + if span: + # The controller type the emulator already wrote stays; only bindings are added. + have = {l.split("=", 1)[0].strip() for l in lines[span[0]:span[1]] if "=" in l} + new = [l for l in new if not (l.split("=", 1)[0].strip() == "Type" and "Type" in have)] + end = span[1] + while end > span[0] and not lines[end - 1].strip(): + end -= 1 + lines[end:end] = new + else: + lines += ["", f"[{section}]"] + new + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + os.replace(path + ".tmp", path) + print(f"bannerlator-pad-defaults: {what}: Player 1 on the controller ({path})") + + +# DuckStation: SDL-/, axes signed (src/util/sdl_input_source.cpp); Pad1 keys from +# src/core/analog_controller.cpp. Its defaults bind the keyboard only (settings.cpp). +DUCKSTATION_PAD1 = """Type = AnalogController +Up = SDL-0/DPadUp +Down = SDL-0/DPadDown +Left = SDL-0/DPadLeft +Right = SDL-0/DPadRight +Cross = SDL-0/A +Circle = SDL-0/B +Square = SDL-0/X +Triangle = SDL-0/Y +Select = SDL-0/Back +Start = SDL-0/Start +Analog = SDL-0/Guide +L1 = SDL-0/LeftShoulder +R1 = SDL-0/RightShoulder +L2 = SDL-0/+LeftTrigger +R2 = SDL-0/+RightTrigger +L3 = SDL-0/LeftStick +R3 = SDL-0/RightStick +LLeft = SDL-0/-LeftX +LRight = SDL-0/+LeftX +LUp = SDL-0/-LeftY +LDown = SDL-0/+LeftY +RLeft = SDL-0/-RightX +RRight = SDL-0/+RightX +RUp = SDL-0/-RightY +RDown = SDL-0/+RightY +LargeMotor = SDL-0/LargeMotor +SmallMotor = SDL-0/SmallMotor +""" + + +def duckstation(): + for d in (os.path.join(CONFIG, "duckstation"), os.path.join(HOME, ".local/share/duckstation")): + patch_ini_section(os.path.join(d, "settings.ini"), "Pad1", DUCKSTATION_PAD1, "DuckStation") + + +# PCSX2: FaceSouth/... names (pcsx2/Input/SDLInputSource.cpp), keys from SIO/Pad/PadDualshock2.cpp. +# An ini without [UI] SettingsVersion is thrown away and rebuilt, so only a complete one is patched. +PCSX2_PAD1 = """Type = DualShock2 +Up = SDL-0/DPadUp +Right = SDL-0/DPadRight +Down = SDL-0/DPadDown +Left = SDL-0/DPadLeft +Triangle = SDL-0/FaceNorth +Circle = SDL-0/FaceEast +Cross = SDL-0/FaceSouth +Square = SDL-0/FaceWest +Select = SDL-0/Back +Start = SDL-0/Start +L1 = SDL-0/LeftShoulder +R1 = SDL-0/RightShoulder +L2 = SDL-0/+LeftTrigger +R2 = SDL-0/+RightTrigger +L3 = SDL-0/LeftStick +R3 = SDL-0/RightStick +Analog = SDL-0/Guide +LUp = SDL-0/-LeftY +LRight = SDL-0/+LeftX +LDown = SDL-0/+LeftY +LLeft = SDL-0/-LeftX +RUp = SDL-0/-RightY +RRight = SDL-0/+RightX +RDown = SDL-0/+RightY +RLeft = SDL-0/-RightX +LargeMotor = SDL-0/LargeMotor +SmallMotor = SDL-0/SmallMotor +""" + + +def pcsx2(): + patch_ini_section(os.path.join(CONFIG, "PCSX2/inis/PCSX2.ini"), "Pad1", PCSX2_PAD1, "PCSX2", + require="SettingsVersion") + + +# Dolphin: SDL//; control names from InputCommon/.../SDL/SDLGamepad.h (Left Y+ +# is up). A section that exists is taken whole - keys it lacks are unbound - so both are complete. +DOLPHIN_GC = """[GCPad1] +Device = SDL/0/Xbox 360 Controller +Buttons/A = `Button S` +Buttons/B = `Button W` +Buttons/X = `Button E` +Buttons/Y = `Button N` +Buttons/Z = `Shoulder R` +Buttons/Start = `Start` +Main Stick/Up = `Left Y+` +Main Stick/Down = `Left Y-` +Main Stick/Left = `Left X-` +Main Stick/Right = `Left X+` +C-Stick/Up = `Right Y+` +C-Stick/Down = `Right Y-` +C-Stick/Left = `Right X-` +C-Stick/Right = `Right X+` +Triggers/L = `Trigger L` +Triggers/R = `Trigger R` +Triggers/L-Analog = `Trigger L` +Triggers/R-Analog = `Trigger R` +D-Pad/Up = `Pad N` +D-Pad/Down = `Pad S` +D-Pad/Left = `Pad W` +D-Pad/Right = `Pad E` +Rumble/Motor = `Motor` +""" +DOLPHIN_WII = """[Wiimote1] +Device = SDL/0/Xbox 360 Controller +Source = 1 +Buttons/A = `Button S` +Buttons/B = `Trigger R` +Buttons/1 = `Button W` +Buttons/2 = `Button N` +Buttons/- = `Back` +Buttons/+ = `Start` +Buttons/Home = `Guide` +D-Pad/Up = `Pad N` +D-Pad/Down = `Pad S` +D-Pad/Left = `Pad W` +D-Pad/Right = `Pad E` +IR/Up = `Right Y+` +IR/Down = `Right Y-` +IR/Left = `Right X-` +IR/Right = `Right X+` +Shake/X = `Button E` +Shake/Y = `Button E` +Shake/Z = `Button E` +Rumble/Motor = `Motor` +Extension = Nunchuk +Nunchuk/Buttons/C = `Shoulder L` +Nunchuk/Buttons/Z = `Trigger L` +Nunchuk/Stick/Up = `Left Y+` +Nunchuk/Stick/Down = `Left Y-` +Nunchuk/Stick/Left = `Left X-` +Nunchuk/Stick/Right = `Left X+` +""" + + +def dolphin(): + d = os.path.join(CONFIG, "dolphin-emu") + seed(os.path.join(d, "GCPadNew.ini"), DOLPHIN_GC, "Dolphin (GameCube)") + seed(os.path.join(d, "WiimoteNew.ini"), DOLPHIN_WII, "Dolphin (Wii)") + + +# Cemu: an SDLController profile for the GamePad. The uuid is "_" and SDL's GUID +# carries a CRC of the device name; this is the one SDL reports for the session's pad +# ("Xbox 360 Controller (0)", 045e:028e - read from RPCS3's SDL log on the device). Mapping ids +# from VPADController.h, values from Controller.h, Cemu's own SDL default layout plus Home. +CEMU_MAP = [(1, 1), (2, 0), (3, 3), (4, 2), (5, 9), (6, 10), (7, 42), (8, 43), (9, 6), (10, 4), + (11, 11), (12, 12), (13, 13), (14, 14), (15, 7), (16, 8), (17, 45), (18, 39), (19, 44), + (20, 38), (21, 47), (22, 41), (23, 46), (24, 40), (27, 5)] +CEMU_XML = """ + +\tWii U GamePad +\t0 +\t +\t\tSDLController +\t\t0_03005e615e0400008e02000010010000 +\t\tXbox 360 Controller +\t\t0.5 +\t\t +""" + "".join(f"\t\t\t\n\t\t\t\t{m}\n\t\t\t\t\n\t\t\t\n" for m, b in CEMU_MAP) + """\t\t +\t + +""" + + +def cemu(): + seed(os.path.join(CONFIG, "Cemu/controllerProfiles/controller0.xml"), CEMU_XML, "Cemu") + + +# melonDS 1.x: raw SDL joystick indices. The session's pad has the kernel's Xbox 360 layout +# (buttons A B X Y LB RB Back Start Guide LS RS = 0..10, hat 0 for the d-pad); the d-pad values +# also take the left stick (EmuInstanceInput.cpp; encoding InputConfig/MapButton.h). +MELONDS_JOY = """[Instance0.Joystick] +A = 1 +B = 0 +X = 3 +Y = 2 +L = 4 +R = 5 +Select = 6 +Start = 7 +Up = 17891585 +Down = 16843012 +Left = 1114376 +Right = 65794 +""" + + +def melonds(): + path = os.path.join(CONFIG, "melonDS/melonDS.toml") + if not os.path.isfile(path): + seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY, "melonDS") + return + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + try: + import tomllib + joy = tomllib.loads(text).get("Instance0", {}).get("Joystick", {}) + except Exception: + return + if any(isinstance(v, int) and v != -1 for v in joy.values()): + return # something is bound already + # Replace melonDS's own all-unbound table with ours (a TOML table can be defined only once). + out, skipping = [], False + for line in text.splitlines(): + t = line.strip() + if t.startswith("["): + skipping = t == "[Instance0.Joystick]" + if not skipping: + out.append(line) + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write("\n".join(out).rstrip("\n") + "\n\n" + MELONDS_JOY) + os.replace(path + ".tmp", path) + print(f"bannerlator-pad-defaults: melonDS: Player 1 on the controller ({path})") + + +# PPSSPP (SDL): its pad defaults leave the shoulders, the right stick and the triggers unbound on +# SDL builds (SDLJoystick.cpp codes vs KeyMapDefaults.cpp); a [ControlMapping] that exists replaces +# every default, so this one is complete, keyboard included (device 1 keyboard, 10 pad). +PPSSPP_CONTROLS = """[ControlMapping] +Up = 1-19,10-19 +Down = 1-20,10-20 +Left = 1-21,10-21 +Right = 1-22,10-22 +Cross = 1-54,10-189 +Circle = 1-52,10-190 +Square = 1-29,10-191 +Triangle = 1-47,10-188 +Start = 1-62,10-197 +Select = 1-66,10-196 +L = 1-45,10-193 +R = 1-51,10-192 +An.Up = 1-37,10-4003 +An.Down = 1-39,10-4002 +An.Left = 1-38,10-4001 +An.Right = 1-40,10-4000 +RightAn.Up = 10-4007 +RightAn.Down = 10-4006 +RightAn.Left = 10-4005 +RightAn.Right = 10-4004 +Pause = 1-111,10-4 +""" + + +def ppsspp(): + seed(os.path.join(CONFIG, "ppsspp/PSP/SYSTEM/controls.ini"), PPSSPP_CONTROLS, "PPSSPP") + + +# RetroArch: the udev joypad driver finds nothing here (no udev database in the runtime); sdl2 +# does, and matches this profile by vendor/product (task_autodetect.c). +RETROARCH_PROFILE = """input_driver = "sdl2" +input_device = "Xbox 360 Controller" +input_vendor_id = "1118" +input_product_id = "654" +input_b_btn = "0" +input_a_btn = "1" +input_y_btn = "2" +input_x_btn = "3" +input_select_btn = "4" +input_menu_toggle_btn = "5" +input_start_btn = "6" +input_l3_btn = "7" +input_r3_btn = "8" +input_l_btn = "9" +input_r_btn = "10" +input_up_btn = "11" +input_down_btn = "12" +input_left_btn = "13" +input_right_btn = "14" +input_l2_axis = "+4" +input_r2_axis = "+5" +input_l_x_plus_axis = "+0" +input_l_x_minus_axis = "-0" +input_l_y_plus_axis = "+1" +input_l_y_minus_axis = "-1" +input_r_x_plus_axis = "+2" +input_r_x_minus_axis = "-2" +input_r_y_plus_axis = "+3" +input_r_y_minus_axis = "-3" +""" + + +def retroarch(): + d = os.path.join(CONFIG, "retroarch") + seed(os.path.join(d, "autoconfig/sdl2/Xbox 360 Controller.cfg"), RETROARCH_PROFILE, "RetroArch (profile)") + cfg = os.path.join(d, "retroarch.cfg") + if not os.path.isfile(cfg): + seed(cfg, 'input_joypad_driver = "sdl2"\ninput_autodetect_enable = "true"\n', "RetroArch") + return + with open(cfg, encoding="utf-8", errors="replace") as f: + lines = f.read().splitlines() + changed = False + for i, l in enumerate(lines): + if l.replace(" ", "").startswith('input_joypad_driver="udev"'): + lines[i] = 'input_joypad_driver = "sdl2"' + changed = True + if changed: + with open(cfg + ".tmp", "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + os.replace(cfg + ".tmp", cfg) + print(f"bannerlator-pad-defaults: RetroArch: joypad driver udev -> sdl2 ({cfg})") + + +for step in (rpcs3, duckstation, pcsx2, dolphin, cemu, melonds, ppsspp, retroarch): try: step() - except OSError as e: + except Exception as e: print(f"bannerlator-pad-defaults: {step.__name__}: {e}") From fa964802d9cf9739da9bc062f62992a1bb09922b Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 22:35:28 -0400 Subject: [PATCH 13/15] PS3: one shader cache, no interpreter precompile - A program from the rail draws with the desktop's Linux driver instead of the Steam session's. The driver's shader cache is keyed on its build, so with two drivers an emulator compiled everything once per way of starting it; seen on device: desktop on the imported Turnip 26.3.0, rail on the runtime's own. - RPCS3's default shader mode builds 6650 interpreter pipelines every boot. bannerlator-pad-defaults starts a new install on Async Recompiler (multi-threaded) and moves an existing one still on the default once; a marker leaves any later choice in RPCS3's settings alone. Enum strings from rpcs3/Emu/system_config_types.cpp. --- .../launcher/session/SessionService.kt | 7 +++- .../usr/local/bin/bannerlator-pad-defaults | 35 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 0cfc3dce5..830cbe71b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -280,7 +280,12 @@ class SessionService : Service() { // An imported glibc Turnip for this mode, when the user chose one: the session script checks // the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES, // so the runtime's own driver above stays untouched and is what a bad import falls back to. - val linuxDriverId = SessionPrefs.linuxDriver(this, SessionPrefs.prefMode(SessionState.mode)) + // A program from the rail is one of the desktop's emulators, so it draws with the desktop's + // Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver + // build, and with two drivers every emulator compiled its shaders twice - once per way of + // starting it (RPCS3's 6650 interpreter variants on each first boot). + val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode) + val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode) LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId) ?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) } // Turnip's own debug switches, for the runtime's driver and everything on it. The file in diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 2c016d100..9d410e4e5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -10,14 +10,14 @@ HOME = os.environ.get("HOME", "/root") CONFIG = os.environ.get("XDG_CONFIG_HOME", os.path.join(HOME, ".config")) -def seed(path, text, what): +def seed(path, text, what, done="Player 1 on the controller"): if os.path.exists(path): return False os.makedirs(os.path.dirname(path), exist_ok=True) with open(path + ".tmp", "w", encoding="utf-8") as f: f.write(text) os.replace(path + ".tmp", path) - print(f"bannerlator-pad-defaults: {what}: Player 1 on the controller ({path})") + print(f"bannerlator-pad-defaults: {what}: {done} ({path})") return True @@ -68,6 +68,37 @@ RPCS3_PLAYER1 = """Player 1 Input: def rpcs3(): seed(os.path.join(CONFIG, "rpcs3/input_configs/global/Default.yml"), RPCS3_PLAYER1, "RPCS3") + rpcs3_shader_mode() + + +# RPCS3's default shader mode (Async Recompiler with Shader Interpreter) builds 6650 interpreter +# pipelines at the start of every boot, minutes on an Adreno. The async recompiler compiles only +# what a game draws, in the background, once. A new install starts with it (a config.yml that +# holds only this is merged over RPCS3's defaults); an existing one still on the default is moved +# once, and a marker keeps any later choice the user makes in RPCS3's own settings. +RPCS3_INTERPRETER = "Async Recompiler with Shader Interpreter" +RPCS3_RECOMPILER = "Async Recompiler (multi-threaded)" # the GUI says "Async Shader Recompiler"; this is the value (system_config_types.cpp) + + +def rpcs3_shader_mode(): + d = os.path.join(CONFIG, "rpcs3") + marker = os.path.join(d, ".droiddeck-shader-mode") + if os.path.exists(marker): + return + cfg = os.path.join(d, "config.yml") + if not os.path.isfile(cfg): + if seed(cfg, f"Video:\n Shader Mode: {RPCS3_RECOMPILER}\n", "RPCS3", f"shader mode {RPCS3_RECOMPILER}"): + open(marker, "w").close() + return + with open(cfg, encoding="utf-8", errors="replace") as f: + text = f.read() + if f"Shader Mode: {RPCS3_INTERPRETER}" in text: + text = text.replace(f"Shader Mode: {RPCS3_INTERPRETER}", f"Shader Mode: {RPCS3_RECOMPILER}", 1) + with open(cfg + ".tmp", "w", encoding="utf-8") as f: + f.write(text) + os.replace(cfg + ".tmp", cfg) + print(f"bannerlator-pad-defaults: RPCS3: shader mode {RPCS3_RECOMPILER} ({cfg})") + open(marker, "w").close() # ---- ini helpers (DuckStation and PCSX2 share one ini dialect, keys may repeat) ------------- From 1a870c5c07c2eb191462d7c4c93c4468fdd6c88e Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 22:49:50 -0400 Subject: [PATCH 14/15] Desktop: Vulkan by default; a renderer that fails to start is remembered vulkan is the default desktop renderer (settings list it first). When labwc cannot start on a GPU renderer the desktop falls back to pixman as before, and now records the renderer with the patched wlroots and Linux driver it failed on, so later desktops go straight to pixman instead of waiting 20 s each time; an app update, a different driver, or choosing a renderer again in the settings (which deletes the record) retries. --- .../java/com/droiddeck/launcher/MainActivity.kt | 2 +- .../droiddeck/launcher/session/SessionPrefs.kt | 13 ++++++++----- .../droiddeck/launcher/ui/ModeSettingsDialog.kt | 2 +- tools/linuxfs/desktop/droiddeck-desktop | 15 ++++++++++++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 39166a984..092682004 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -169,7 +169,7 @@ class MainActivity : ComponentActivity() { private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) - private var renderer by mutableStateOf("pixman") + private var renderer by mutableStateOf("vulkan") private var gameStorage by mutableStateOf("") private var storageOptions by mutableStateOf>>(emptyList()) private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 8efbe7156..a2c1dbe77 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -330,15 +330,18 @@ object SessionPrefs { } /** - * What the desktop shell composites with: pixman (software, the default), or vulkan / gles2 on - * the GPU - those need the app's patched wlroots (tools/wlroots), since the Adreno stand-in is - * not a DRM device and stock wlroots cannot allocate on it. `Download/droiddeck-wlr-renderer` - * still overrides it. + * What the desktop shell composites with: vulkan (the default) or gles2 on the GPU, through the + * app's patched wlroots (tools/wlroots) - the Adreno stand-in is not a DRM device and stock + * wlroots cannot allocate on it - or pixman in software. A GPU renderer a device cannot start + * falls back to pixman by itself (droiddeck-desktop). `Download/droiddeck-wlr-renderer` still + * overrides it. */ - fun desktopRenderer(context: Context): String = prefs(context).getString("desktopRenderer", "pixman") ?: "pixman" + fun desktopRenderer(context: Context): String = prefs(context).getString("desktopRenderer", "vulkan") ?: "vulkan" fun setDesktopRenderer(context: Context, renderer: String) { prefs(context).edit().putString("desktopRenderer", renderer).apply() + // A choice made again is a retry: forget that a renderer failed to start here before. + java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-renderer-failed").delete() } /** diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 9183c0781..7460914bc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -322,7 +322,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { if (!steam && s.renderer != null) SettingsGroup("Renderer") { ChoiceRow( host, "renderer", "Desktop renderer", "Composites the desktop.", - listOf("pixman" to "pixman - software", "vulkan" to "vulkan - GPU (experimental)", "gles2" to "gles2 - GPU (experimental)"), s.renderer, + listOf("vulkan" to "vulkan - GPU", "gles2" to "gles2 - GPU (experimental)", "pixman" to "pixman - software"), s.renderer, note = "On Vulkan, programs on the desktop draw with the GPU in their own windows; if it cannot start, " + "the desktop comes up on pixman. On pixman, games and emulators from the menu open full screen " + "on the GPU instead (right-click one for a desktop window).", diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 1f4c018b4..3f2ace30b 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -19,7 +19,7 @@ export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1 # stock wlroots allocates through gbm on a real DRM node and the KGSL stand-in is not one ("unable # to create allocator"): they need the app's patched wlroots (tools/wlroots), whose Vulkan # allocator exports the buffers instead. BL_WLR_RENDERER picks; see the fallback at the end. -export WLR_RENDERER=${BL_WLR_RENDERER:-pixman} +export WLR_RENDERER=${BL_WLR_RENDERER:-vulkan} WLROOTS_DIR=/usr/local/lib/droiddeck-wlroots BASE_LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-} use_renderer() { @@ -33,6 +33,18 @@ use_renderer() { fi } use_renderer "$WLR_RENDERER" +# A GPU renderer that failed to start here is not tried again on every desktop start (each try costs +# up to 20 s): the failure is kept with what it failed on - the renderer, the patched wlroots and the +# Linux driver - so an app update or a different driver retries, and so does picking a renderer +# again in the app's settings (which deletes the file). +FAILED_FILE=$HOME/.droiddeck-renderer-failed +renderer_key() { + echo "$WLR_RENDERER $(md5sum "$WLROOTS_DIR/libwlroots-0.20.so" 2>/dev/null | cut -d' ' -f1) ${BL_VK_DRIVER:-runtime}" +} +if [ "$WLR_RENDERER" != pixman ] && [ "$(cat "$FAILED_FILE" 2>/dev/null)" = "$(renderer_key)" ]; then + echo "== droiddeck-desktop: $WLR_RENDERER failed to start here before; using pixman (choose a renderer in the settings to retry)" + use_renderer pixman +fi # The desktop's own log, beside the session's, so labwc and the programs on it can be read apart # from the session that started them. The session log keeps the milestones either way. if [ -n "${BL_DEBUG_DIR:-}" ]; then @@ -175,6 +187,7 @@ else fi if [ "$rc" -ne 0 ] && [ $(( $(date +%s) - started )) -lt 40 ]; then echo "== droiddeck-desktop: labwc on $WLR_RENDERER failed at start (exit $rc); starting on pixman instead" + renderer_key > "$FAILED_FILE" use_renderer pixman DEBUG=; [ -f "$HOME/.droiddeck-desktop-debug" ] && DEBUG=-d find "$XDG_RUNTIME_DIR" -mindepth 1 -maxdepth 1 ! -name 'wayland-0*' ! -name 'keymap.xkb' -exec rm -rf {} + 2>/dev/null From 24188bda107698376b5e90d1d35349ac94c6e3ff Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 22:59:44 -0400 Subject: [PATCH 15/15] Compositor: xdg_positioner, xdg_popup and wl_output requests no longer abort Positioners and outputs were created without an implementation, and get_popup created nothing. libwayland aborts on the first request to a resource with no implementation - the compositor and the app with it. On device: a gamescope started from the pixman desktop (droiddeck-gpu) beside labwc took the app down (tombstone: abort in libwayland-server under wl_event_loop_dispatch). Positioner requests are accepted and ignored, popups are dismissed at once (popup_done), wl_output.release destroys the resource. --- app/src/main/cpp/waylandcomp/src/compositor.c | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 36b500eae..55fb52158 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -1332,8 +1332,23 @@ static void xdg_surface_get_toplevel(struct wl_client *c, struct wl_resource *r, s->xdg_toplevel = tl; send_toplevel_configure(s); } +/* A popup is dismissed as soon as it is made (see the positioner below). */ +static void popup_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } +static void popup_grab(struct wl_client *c, struct wl_resource *r, struct wl_resource *seat, uint32_t serial) {} +static void popup_reposition(struct wl_client *c, struct wl_resource *r, struct wl_resource *positioner, + uint32_t token) {} +static const struct xdg_popup_interface popup_impl = { + .destroy = popup_destroy, + .grab = popup_grab, + .reposition = popup_reposition, +}; static void xdg_surface_get_popup(struct wl_client *c, struct wl_resource *r, uint32_t id, - struct wl_resource *parent, struct wl_resource *positioner) {} + struct wl_resource *parent, struct wl_resource *positioner) { + struct wl_resource *p = wl_resource_create(c, &xdg_popup_interface, wl_resource_get_version(r), id); + if (!p) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(p, &popup_impl, NULL, NULL); + xdg_popup_send_popup_done(p); +} static void xdg_surface_set_geometry(struct wl_client *c, struct wl_resource *r, int32_t x, int32_t y, int32_t w, int32_t h) {} static void xdg_surface_ack_configure(struct wl_client *c, struct wl_resource *r, uint32_t serial) {} @@ -1350,9 +1365,40 @@ static void xdg_surface_resource_destroy(struct wl_resource *r) { } static void xdg_wm_base_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } + +/* Popups are not drawn here, but their requests must still land somewhere: a resource without an + * implementation makes libwayland abort on the first request to it - the whole compositor, and the + * app with it. A positioner takes every request and keeps nothing; a popup is dismissed at once + * (popup_done), which is what a client does with a popup it cannot show. Seen with a gamescope + * started from the Linux desktop beside labwc. */ +static void positioner_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } +static void positioner_set_size(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h) {} +static void positioner_set_anchor_rect(struct wl_client *c, struct wl_resource *r, int32_t x, int32_t y, + int32_t w, int32_t h) {} +static void positioner_set_anchor(struct wl_client *c, struct wl_resource *r, uint32_t a) {} +static void positioner_set_gravity(struct wl_client *c, struct wl_resource *r, uint32_t g) {} +static void positioner_set_constraint_adjustment(struct wl_client *c, struct wl_resource *r, uint32_t a) {} +static void positioner_set_offset(struct wl_client *c, struct wl_resource *r, int32_t x, int32_t y) {} +static void positioner_set_reactive(struct wl_client *c, struct wl_resource *r) {} +static void positioner_set_parent_size(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h) {} +static void positioner_set_parent_configure(struct wl_client *c, struct wl_resource *r, uint32_t serial) {} +static const struct xdg_positioner_interface positioner_impl = { + .destroy = positioner_destroy, + .set_size = positioner_set_size, + .set_anchor_rect = positioner_set_anchor_rect, + .set_anchor = positioner_set_anchor, + .set_gravity = positioner_set_gravity, + .set_constraint_adjustment = positioner_set_constraint_adjustment, + .set_offset = positioner_set_offset, + .set_reactive = positioner_set_reactive, + .set_parent_size = positioner_set_parent_size, + .set_parent_configure = positioner_set_parent_configure, +}; + static void xdg_wm_base_create_positioner(struct wl_client *c, struct wl_resource *r, uint32_t id) { struct wl_resource *p = wl_resource_create(c, &xdg_positioner_interface, wl_resource_get_version(r), id); - if (p) wl_resource_set_implementation(p, NULL, NULL, NULL); + if (!p) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(p, &positioner_impl, NULL, NULL); } static void xdg_wm_base_get_xdg_surface(struct wl_client *c, struct wl_resource *r, uint32_t id, struct wl_resource *surf) { @@ -1738,9 +1784,15 @@ static void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t /* ------------------------------------------------------------------ wl_output */ +/* wl_output v3+ has a request (release); without an implementation the first client to send it + * aborted the compositor. */ +static void output_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } +static const struct wl_output_interface output_impl = { .release = output_release }; + static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) { struct wl_resource *r = wl_resource_create(c, &wl_output_interface, ver, id); - wl_resource_set_implementation(r, NULL, NULL, NULL); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &output_impl, NULL, NULL); wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN, "Bannerlator", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL); wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,