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/.github/workflows/build.yml b/.github/workflows/build.yml index b4669f717..a5965ffe2 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" @@ -306,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 diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index dbf4551b2..55fb52158 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 */ @@ -589,6 +590,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(); } @@ -884,10 +889,21 @@ 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; + /* 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)); @@ -1298,6 +1314,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; @@ -1315,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) {} @@ -1333,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) { @@ -1721,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, 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/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/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 78b09fc5e..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", @@ -56,6 +57,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 +79,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..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,14 +330,18 @@ 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: 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/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 07241da90..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 @@ -321,13 +326,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 +403,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") @@ -407,12 +416,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..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,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("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).", + onPick = a.onRenderer, ) } } 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..ad3b34405 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -0,0 +1,251 @@ +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() } } + + 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 = 5.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) + Spacer(Modifier.width(8.dp)) + HeaderButton("Hide ✕", onClose) + } + ROWS.forEachIndexed { r, row -> + 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) + 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) 11.sp else 15.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") { 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 diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 8a90ef8d1..3f2ace30b 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -14,9 +14,37 @@ 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. -export WLR_RENDERER=${BL_WLR_RENDERER:-pixman} +# 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:-vulkan} +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" +# 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 @@ -85,12 +113,86 @@ 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 - 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. +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" +# ~/.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. + 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 +} +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 -exec labwc $DEBUG -C "$HOME/.config/labwc" +# 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 & +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" + 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 + 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 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..9d410e4e5 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -0,0 +1,457 @@ +#!/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, 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}: {done} ({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") + 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) ------------- + +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 Exception 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 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. 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