diff --git a/AGENTS.md b/AGENTS.md index c18c532..69ce721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,11 +48,15 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine `_available` is checked via `component_available` (e.g. `machine` only on Fremont). Turn-on order is `COMPONENTS` order, turn-off reverse; `gaming` must stay first. Dependencies live in `toggle_component`. -- **NVIDIA** (`lib/nvidia.sh`): gamescope's session is broken on NVIDIA, so the SteamOS conversion (`gaming`, `boot`, `single`, - `glyphs`) is hidden there (unless already on) and `nvidia` + its sub-option `bigpicture` ("Gaming on NVIDIA") replace it: - Steam on the Plasma desktop, started at login. Never reintroduce a gamescope session for NVIDIA without re-testing on the - hardware (notes: steamify-cachyos-dev, `nvidia/`). It can't be tested without the hardware: keep the branch behind - `.no-release-yet` until it was. +- **NVIDIA** (`lib/gamescope-nvidia.sh`, `lib/nvidia.sh`): since 2.12.0 the SteamOS conversion (`gaming` and its options) runs on + NVIDIA PCs with a patched gamescope (GBM scan-out: gamescope#2309; Steam's UI kept at the display's size), downloaded as the + newest release of theupriser/steamify-gamescope-nvidia, with the pinned package (`GSN_VERSION`, `GSN_SHA256`), the copy the ISO + leaves, and a build here (`patches/gamescope-nvidia.PKGBUILD` + the two patches) as the fallback. "Gaming on NVIDIA" (`nvidia`, + `bigpicture`, `nvsilent`) isn't offered any more (`nvidia_available`); turning the conversion on turns it off (`nvidia_swap_out`). + Builds exist per CPU level (`gsn_cpu_variant`: znver4, v4, v3, generic; `GSN_SHA256` is a map; the installed one must pass `gsn_smoke_test`, else generic). A new pin = the release's tag + each variant's sha256 (from `SHA256SUMS`) in `lib/gamescope-nvidia.sh` and the PKGBUILD's pkgver/pkgrel, copied from the + repository (the patches in `patches/` must stay identical to that repository's). Steam must keep running with `-steamos3` + (without it games don't launch in gaming mode and the settings pages go): never "fix" the UI size by dropping flags or by + `XWAYLAND_COUNT`. Tested on an RTX 5080 only; keep the branch behind `.no-release-yet` until the whole flow ran on hardware. - **Feature versions.** Set `FEATURE_VERSION[]` (`lib/menu.sh`) to the new `VERSION` whenever what `_enable` sets up changes (a new component gets one too): installs recorded with an older version @@ -156,9 +160,8 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine a warning when `VERSION` is a version that's already released (so a release branch that forgot its bump shows it on every PR). - A release that adds, removes or renames a menu row also retakes the - README screenshots (`assets/screenshot-menu-steam-machine.png` and - `assets/screenshot-menu-nvidia.png`, side by side in the README): the app from the branch on a Steam Machine and on a PC - with an NVIDIA card, every row visible and each window only as tall as its options need, the header showing the new + README screenshot (`assets/screenshot-menu-steam-machine.png`; NVIDIA PCs get the same menu, so there is no separate + one): the app from the branch on a Steam Machine, every row visible and the window only as tall as its options need, the header showing the new `VERSION`, 1600 px wide. Size the window with a KWin script (match the `steamify-ui` class exactly: a title match also hits Konsole and editor windows) and crop the full-screen capture to the client rectangle; mind the display scale (the Steam Machine runs at 1.75). diff --git a/CHANGELOG.md b/CHANGELOG.md index a821bcf..656114e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes, per version and per commit. Versions follow `steamify.sh`. Versions before 0.7.0 were numbered afterwards, one per merged pull request. +## 2.12.0 - 2026-10-13 + +- **feat: setting up the SteamOS conversion on NVIDIA first checks that nothing blocks it (a pacman lock, less than 1.5 GB free), and a failed setup is remembered: a desktop notification now and a reminder in the menu until a setup works** +- **docs: the README and TECHNICAL.md describe NVIDIA as it is now (the SteamOS conversion with the patched gamescope; "Gaming on NVIDIA" only as history); the NVIDIA menu screenshot is gone, NVIDIA PCs get the same menu** +- **feat: the patched gamescope comes in one build per CPU level, like CachyOS's repositories (generic, x86-64-v3, v4, znver4): Steamify picks the best one this CPU can run (`gsn_cpu_variant`), falls back to generic, and starts the installed gamescope once headless (`gsn_smoke_test`): a build that doesn't start is replaced by the generic one. CI compiles each with an explicit `-march` (a `-march=native` build died with SIGILL on a Ryzen) and checks the binary for instructions beyond its level.** +- **feat: on NVIDIA the gaming session runs on the display's own mode and Steam's interface at its size: a second gamescope patch (`gamescope-ui-native.patch`, release `3.16.31-3` of steamify-gamescope-nvidia, now the pin; built once per CPU level, see below) keeps Steam's UI X server at the output size when Steam (with `-steamos3`) asks for 1920x1080, and a wrapper (`gamescope-display-wrapper.sh`, set up by the conversion with a drop-in of `gamescope-session.service`) starts gamescope on the mode KDE has saved for the connected display, else the biggest mode at the highest refresh rate: before, gaming mode ran at the display's preferred mode (60 Hz on many monitors) with a 1080p Steam UI. Tested on an RTX 5080, 3440x1440@175: widescreen, sharp, a Proton game starts** + +- **fix: on an NVIDIA PC that had single user mode on before the SteamOS conversion, "Return to Gaming Mode" and the boot choice work again: single user mode's desktop autologin (`zzz-steamify-autologin.conf`, read last) pinned the Plasma session; the conversion removes it, and turning the conversion off sets it up again** + +- **fix: when an option fails, its sub-options are not applied any more (a failed conversion used to leave its "Boot into the desktop", silent start and icons on without it)** + +- **fix: a PC that keeps `python-hidapi` (liquidctl needs it) can use the SteamOS conversion: jupiter-hw-support, which gamescope-session-cachyos depends on, wants `python-hid`, which conflicts with it and stopped the package install. Steamify builds and installs an empty placeholder package that provides `python-hid` first (`patches/python-hid-shim.PKGBUILD`); only the Steam Deck controller firmware updater uses it** + +- **feat: the patched gamescope is downloaded as the newest release of steamify-gamescope-nvidia (checked against that release's `.sha256`), a newer one than the installed package shows the SteamOS conversion as an update, and what this Steamify was tested with is the fallback: the pinned package (sha256 in Steamify), the copy the ISO leaves, a build here; the repository's workflow builds each release in CachyOS's container** + +- `124b424`, `f234c72` **fix: HDMI-CEC on the Steam Machine no longer needs GitHub: when downloading Valve's CEC driver fails, Steamify uses the copy it carries (`patches/cros-ec-cec.c`), checked against the same pinned sha256** + +- `127b8cf`, `2811ecc`, `f3cd053`, `4325d80`, `03f9e87`, `5b5181f`, `06ffc8b`, `4d60dd2`, `0ba94b2` **feat: the SteamOS conversion is offered on NVIDIA PCs again (GTX 16 / RTX 20 series or newer: Turing and later, the cards NVIDIA's open kernel driver supports): it builds and installs a patched gamescope from the recipe and patch Steamify carries (`patches/`, prepared in [steamify-gamescope-nvidia](https://github.com/theupriser/steamify-gamescope-nvidia); gamescope 3.16.31, so a Steamify release builds the version it was tested with) (GBM-allocated scan-out buffers, NVIDIA's corrupted gaming-mode picture, gamescope#2309; a few minutes to build) and sets `gamescope_drm_gbm_scanout=1` for the session (`lib/gamescope-nvidia.sh`). It replaces "Gaming on NVIDIA", which is not offered any more (the kernel parameters, early modules, suspend memory and the 32-bit libraries come with the conversion on a supported card; a PC that had "Gaming on NVIDIA" on has it turned off when the conversion is turned on). Tested on an RTX 5080 (driver 615.78.08): Steam's gaming UI is clean with it and shows artifacts without. The package is pinned to one gamescope release: updating it is not automatic yet** + +- `ce4bc67` **feat: "Gaming on NVIDIA" also keeps video memory over suspend (`NVreg_PreserveVideoMemoryAllocations=1` in `/etc/modprobe.d/90-steamify-nvidia.conf`, the initramfs is rebuilt), enables `nvidia-suspend`/`nvidia-resume`/`nvidia-hibernate`, and installs `lib32-nvidia-utils` and `lib32-libxkbcommon` (32-bit games, the 32-bit MangoHud preload) on a supported card; off removes only what Steamify added. Untested on hardware yet** + ## 2.11.1 - 2026-10-02 - `d70aeb8` **docs: new README screenshots of the app (2.11 menu) on a Steam Machine and on an NVIDIA PC, and the README names the opt-in "Start Steam silently in desktop mode" and the icons as an option of the conversion** diff --git a/README.md b/README.md index ad57ed9..5b60f2c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,9 @@ # Steamify CachyOS -The Steamify app's menu. On a Steam Machine (or any PC with an AMD or Intel -graphics card) it offers the full SteamOS conversion, on the left. On a PC with -an NVIDIA graphics card gamescope's gaming mode shows a corrupted picture, so the -menu offers **Gaming on NVIDIA** instead (Steam on the desktop, started at login, -optionally in Big Picture), on the right. +The Steamify app's menu, on a Steam Machine. On a PC with an NVIDIA graphics card (GTX 16 / RTX 20 series or +newer) the same SteamOS conversion is offered; Steamify installs a patched gamescope for it. -| On a Steam Machine | On a PC with an NVIDIA graphics card | -|---|---| -| ![The Steamify app's menu on a Steam Machine](assets/screenshot-menu-steam-machine.png) | ![The Steamify app's menu on a PC with an NVIDIA graphics card](assets/screenshot-menu-nvidia.png) | +![The Steamify app's menu on a Steam Machine](assets/screenshot-menu-steam-machine.png) Turn a CachyOS desktop PC into a SteamOS-style console: it boots straight into Steam's Big Picture (gamescope), and you can switch to the KDE Plasma @@ -35,12 +30,8 @@ Steam Machine. as OS Version (e.g. `steamos-3.9`), `steam-machine` as OS Codename and Steamify's version as OS Variant. The OS name stays CachyOS ([details](TECHNICAL.md#what-steams-system-settings-show)). - - **NVIDIA graphics cards:** gamescope's gaming mode shows a corrupted - picture there (an open NVIDIA driver bug), so the conversion isn't - offered. **Gaming on NVIDIA** takes its place: Steam on the desktop, - installed if it's missing and started at login, in **Big Picture** or its - normal window as you choose - ([details](TECHNICAL.md#gaming-on-nvidia)). + - **NVIDIA support**: GeForce GTX 16 series and RTX 20, 30, 40 and 50 series + (Turing and newer) ([details](TECHNICAL.md#nvidia-graphics-cards)). 2. **SteamOS theme** - the Vapor look of SteamOS for the desktop, with **Add to Steam** in right-click menus ([details](TECHNICAL.md#steamos-desktop-look)). @@ -55,7 +46,7 @@ Steam Machine. With **Add as non-Steam game** (on by default) it's in your Steam library too: started from Steam it gets your controller (a Steam Controller only talks to Steam), in gaming mode as well. Steam closes for a moment while - it's added, so that's done from the desktop. Not offered on PCs with an NVIDIA graphics card, which have no gaming mode. + it's added, so that's done from the desktop. Not offered on PCs with an NVIDIA graphics card yet (not tested there). 6. **Update notifications** - on by default: a notification and a tray icon when there's a new Steamify, with Open Steamify and Skip this version. It never updates anything by itself @@ -187,6 +178,18 @@ Contributing (or an AI agent)? Read [AGENTS.md](AGENTS.md). MIT - do whatever you want with it. See [LICENSE.md](LICENSE.md). +### Code from other projects + +Some files in `patches/` are not MIT. They keep their own license, and each file says where it comes from: + +| File | What | License and source | +|---|---|---| +| `steamify-fremont-poweroff.c` | Kernel module for the Steam Machine's power-off fix. It does what Valve's Steam Machine kernel does for this (clears the S4 wake bit of GPIO pin 18 at probe), but at power-off, for kernels without Valve's change. Valve's kernel: [evlaV/linux-integration](https://github.com/evlaV/linux-integration). | GPL-2.0, like the Linux kernel it is built into | +| `cros-ec-cec.c` | Valve's Steam Machine HDMI-CEC driver (ChromeOS EC CEC, Copyright BayLibre; Valve's version of it), at a pinned commit of [evlaV/linux-integration](https://github.com/evlaV/linux-integration). Used when it can't be downloaded. | GPL-2.0+, unmodified (Steamify's `cros-ec-cec-single-port.patch` is applied when it is built) | +| `gamescope-gbm-scanout.patch` | GBM scan-out for gamescope on NVIDIA: nine commits of [farnoy/gamescope](https://github.com/farnoy/gamescope) on [ValveSoftware/gamescope](https://github.com/ValveSoftware/gamescope). | gamescope's license (BSD-2-Clause) | +| `gamescope-ui-native.patch` | Keeps Steam's interface at the display's size in gamescope on NVIDIA (a small patch after the one above). | gamescope's license (BSD-2-Clause) | +| `gamescope-nvidia.PKGBUILD` | The recipe for the patched gamescope package, used only when it can't be downloaded. | MIT, like Steamify | + ## Disclaimer Steamify CachyOS is an unofficial community project. It is not affiliated diff --git a/TECHNICAL.md b/TECHNICAL.md index a94ee5f..ef4472b 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -43,7 +43,7 @@ user wallet, with anything saved in it meanwhile, is kept as `kdewallet.kwl.steamify-single-user` and used again the next time it's on. Passwords aren't shared between the two wallets. -**Without the conversion (NVIDIA PCs, where gamescope's session is broken)** nothing else logs in by itself, so single user mode +**Without the conversion** (any PC with it off; on NVIDIA that is before 2.12.0 or after turning it off) nothing else logs in by itself, so single user mode does that too: `single_login_enable` (`lib/single-user.sh`) switches to SDDM and writes `/etc/sddm.conf.d/zzz-steamify-autologin.conf` (`User=`, `Session=plasma.desktop`, `Relogin=true`; named to sort after `steam-set-session`'s `zz-steamos-autologin.conf`, which may still say gamescope from an earlier conversion), and drops any @@ -229,19 +229,77 @@ headers first. A PC that already has the AUR's `xone-dkms-git` keeps it: it prov `extended_controller_support`, `installed_pkgs`); the headers stay. The drivers load when the device shows up: the dongle is plugged in again, or the controller reconnected. Its test is in steamify-cachyos-dev (`tests/controllers-test.sh`). -## Gaming on NVIDIA +## NVIDIA graphics cards -gamescope's own gaming mode shows a corrupted picture on NVIDIA graphics cards: NVIDIA's open bug 5240452 (flicker and -artifacts in the DRM session at modes above 2560x1440@120, seen up to driver 595 and on RTX 3070 Ti, 4090, 5090 and 5080; -no fix known). The kernel parameters `nvidia-drm.modeset=1`/`fbdev=1` and loading the NVIDIA modules early don't change it -(the driver already reports both `Y`), and gamescope started nested inside KDE artifacts too when KWin hands its fullscreen -window straight to the display. What does work on an RTX 5080: Steam's Big Picture (`steam -gamepadui`) as a normal window of -the Plasma desktop session, smooth and clean. +**The problem.** gamescope's gaming mode shows a corrupted picture on NVIDIA graphics cards: NVIDIA's open bug 5240452 (flicker +and artifacts in the DRM session at modes above 2560x1440@120, seen up to driver 595 and on RTX 3070 Ti, 4090, 5090 and 5080; also +[gamescope#2309](https://github.com/ValveSoftware/gamescope/issues/2309)). The kernel parameters `nvidia-drm.modeset=1`/`fbdev=1` +and loading the NVIDIA modules early don't change it, and gamescope nested inside KDE artifacts too. -So with an NVIDIA GPU (PCI vendor 0x10de, display class, its driver providing `nvidia_drm`: `nvidia_present`) the SteamOS -conversion (`gaming`, and its options `boot`, `single`, `glyphs`) isn't offered, unless it is already on (so it can be turned -off). `nvidia` ("Gaming on NVIDIA", `lib/nvidia.sh`) takes its place, hidden while the conversion is on (both would start -Steam at login): it installs `steam` when missing (recorded in state `nvidia`, removed again only then) and enables the user +**The fix.** gamescope with GBM-allocated scan-out buffers, opt-in through the environment variable +`gamescope_drm_gbm_scanout=1`. The patch is nine commits of [farnoy/gamescope](https://github.com/farnoy/gamescope) +(`nvidia-gbm-wayland`). A second small patch keeps Steam's UI server (Steam with `-steamos3` asks gamescope for 1920x1080) at the +display's size. Both are built into one package, `steamify-gamescope-nvidia` (it provides and conflicts with `gamescope`), in +[theupriser/steamify-gamescope-nvidia](https://github.com/theupriser/steamify-gamescope-nvidia): the patches, the PKGBUILD, and CI. +Tested on an RTX 5080 (driver 615.78.08, 3440x1440@175): clean picture, widescreen and sharp Steam UI, Proton games start. +Everything below is `lib/gamescope-nvidia.sh` unless named otherwise. + +**What the SteamOS conversion does on an NVIDIA PC** (`gaming_enable` calls `gsn_enable`; `nvidia_present`: PCI vendor 0x10de, +display class, its driver providing `nvidia_drm`; `nvidia_supported`: GTX 16 / RTX 20 series or newer, Turing and later): +1. `gsn_preflight`: no pacman lock, at least 1.5 GB free on `/`. A failure stops before anything changes. +2. `nvidia_swap_out`: turns "Gaming on NVIDIA" off if it was on (see below). +3. On a supported card the kernel parameters, early modules, suspend memory and 32-bit libraries (`nvidia_kernel_enable`, + `nvidia_extras_enable`, `lib/nvidia.sh`, described below). +4. `gsn_install_package`: the patched gamescope (next paragraph). +5. `gsn_env_enable`: `gamescope_drm_gbm_scanout=1` in `~/.config/gamescope-session/environment` and + `~/.config/environment.d/99-steamify-gamescope-nvidia.conf`. +6. `gsn_display_enable`: the display wrapper. `patches/gamescope-display-wrapper.sh` is installed as `gamescope` in Steamify's bin + folder and put first in the gaming session's PATH with a drop-in of `gamescope-session.service` (`steamify-display.conf`). It + starts gamescope on the mode KDE has saved for the connected display (`~/.config/kwinoutputconfig.json`, modes from + `modetest`), else the biggest mode at the highest refresh rate; with several displays connected it passes nothing and gamescope + chooses. `--print-args` shows what it would use. +7. `single_login_release` (`lib/single-user.sh`): single user mode's `zzz-steamify-autologin.conf` is read last and would pin the + Plasma session, so it goes (turning the conversion off sets it up again). `python_hid_shim_ensure` (`lib/packages.sh`): the + conversion's dependency jupiter-hw-support wants `python-hid`, which conflicts with `python-hidapi` (liquidctl needs it): an + empty package providing `python-hid` (`patches/python-hid-shim.PKGBUILD`) makes both work. +Turning the conversion off removes the setting, the wrapper and the kernel settings, and keeps the packages. When the setup +fails, `gsn_fail_mark` records it (state `gamescope_nvidia`, key `failed`) and sends a desktop notification; the menu reminds +(`gsn_fail_note`) until a setup works. A failed option's sub-options aren't applied. + +**Getting the package, and keeping it current.** gamescope changes every few weeks, so the package is downloaded, not carried. +CI builds it in CachyOS's container, once per CPU level like CachyOS's repositories: generic, x86-64-v3, v4 and znver4, each with an +explicit `-march` (a `-march=native` build died with SIGILL on a Ryzen) and checked for instructions beyond its level and for both +patches. A release is `-` (for example `3.16.31-3`) with `SHA256SUMS`. `gsn_cpu_variant` +picks the build for this CPU (the dynamic loader's x86-64 level; AMD with `avx512_bf16` is znver4) and generic is the fallback. +`gsn_fetch_package` tries, for the CPU's variant and then generic: the newest release on GitHub (if newer than the pin), the release +Steamify was tested with (`GSN_VERSION`, hashes in `GSN_SHA256`: from GitHub, the cache, or the copy the ISO leaves in +`/usr/local/share/steamify/gamescope`); only files with the expected sha256 count (newer releases: their `.sha256`). If nothing is +reachable, `gsn_build_package` builds from `patches/gamescope-nvidia.PKGBUILD` and the two patches (`makepkg` as the user in +`~/.cache/steamify`). `gsn_install_package` installs with `pacman -U --ask 4`; `gsn_smoke_test` then starts gamescope headless +and installs the generic build instead if it doesn't start. Nothing else changes before a package is installed. A newer release than +the installed package shows the conversion as an update (`gsn_update_pending`, looked up at most every six hours, quiet offline). +A new pin = the release's tag and each variant's sha256 in `GSN_VERSION` and `GSN_SHA256` plus the PKGBUILD's pkgver/pkgrel; the patches in `patches/` must +stay identical to that repository's. New gamescope versions: the repository's daily check bumps the version when the patch still +applies, or opens an issue when it needs a rebase; a person merges and tags the release. + +**What the kernel side does** (`lib/nvidia.sh`; a supported card only): `nvidia-drm.modeset=1 nvidia-drm.fbdev=1` on the kernel command +line (Limine, systemd-boot or GRUB; the file is backed up first; on Limine into the existing `KERNEL_CMDLINE[default]="..."` or +`+="..."` line, never an extra appended line, which ends up as text), and `/etc/mkinitcpio.conf.d/90-steamify-nvidia.conf` +(`MODULES+=(nvidia nvidia_modeset nvidia_uvm nvidia_drm)`), then the initramfs and boot entries are rebuilt and checked. The +early-load drop-in is only there while every installed kernel has the NVIDIA modules (mkinitcpio fails on a missing module): a +pacman hook (`/etc/pacman.d/hooks/85-steamify-nvidia-initramfs.hook`) runs `/usr/local/libexec/steamify-nvidia-initramfs` at every +kernel or driver change and decides again. `nvidia_extras_enable` adds `NVreg_PreserveVideoMemoryAllocations=1` +(`/etc/modprobe.d/90-steamify-nvidia.conf`), enables `nvidia-suspend`/`resume`/`hibernate` and installs `lib32-nvidia-utils` and +`lib32-libxkbcommon`. Takes effect at the next boot. The tests are in steamify-cachyos-dev: `tests/nvidia-test.sh` (a fake GPU, a +stub `pacman` and `systemctl`, a temp home) and `tests/nvidia-hardware-test.sh` (a real NVIDIA PC). + +**Limits.** Tested on one PC (RTX 5080, Ryzen). The "Add as non-Steam game" option isn't offered on NVIDIA PCs yet. Steam must keep +running with `-steamos3` (the Display settings' "Maximum Game Resolution" exists only with it, and dropping it or setting +`XWAYLAND_COUNT=1` stopped games from launching). If upstream fixes the bug, the patched package and this section go, and Steamify +goes back to the stock gamescope. + +**"Gaming on NVIDIA" (before 2.12.0).** With an NVIDIA GPU the SteamOS conversion (`gaming`, and its options `boot`, `single`, `glyphs`) wasn't offered, unless +it was already on. `nvidia` ("Gaming on NVIDIA", `lib/nvidia.sh`) took its place (both would start Steam at login): it installs `steam` when missing (recorded in state `nvidia`, removed again only then) and enables the user unit `steamify-steam-autostart.service` (`services/`, `ExecStart=/usr/bin/steam @ARGS@`, Plasma only). Its sub-option `bigpicture` ("Steam starts in Big Picture", ticked along with it) rewrites the unit with `-gamepadui` and sets `ksmserverrc [General] loginMode=emptySession` (`kset`, undone with `krevert bigpicture`), so windows of the last session @@ -572,6 +630,7 @@ immediately, which can turn into a loop - see | `lib/update-notifier.sh` | Update notifications: the notifier from `patches/` and its user timer | | `lib/first-login.sh` | `--defaults` without a session (the Steam Machine ISO's installer): the one-time first-login step that sets up single user's launcher on Plasma's new layout and opens the app | | `lib/controllers.sh` | Extended controller support: Xbox wireless dongle (xone) and Xbox Bluetooth (xpadneo) from the CachyOS repo | +| `lib/gamescope-nvidia.sh` | gamescope on NVIDIA: builds and installs the patched gamescope (GBM scan-out) from steamify-gamescope-nvidia and sets `gamescope_drm_gbm_scanout=1` for the SteamOS conversion | | `lib/nvidia.sh` | Gaming on NVIDIA: Steam on the desktop, started at login, optionally in Big Picture (replaces the SteamOS conversion there) | | `lib/vram-booster.sh` | VRAM booster (`dmemcg-booster`, `plasma-foreground-booster`) | | `services/` | The systemd units the scripts install (`service_file`, `@KEY@` placeholders); see its README | diff --git a/assets/screenshot-menu-nvidia.png b/assets/screenshot-menu-nvidia.png deleted file mode 100644 index 5c936fc..0000000 Binary files a/assets/screenshot-menu-nvidia.png and /dev/null differ diff --git a/lib/backend.sh b/lib/backend.sh index f205da8..68e4556 100644 --- a/lib/backend.sh +++ b/lib/backend.sh @@ -147,7 +147,6 @@ backend_apply() { for id in "${want[@]}"; do [[ "$id" == "$c" ]] && WANTED[$c]=1; done done # The same rules as the menu's toggles. - # (Single user mode only needs the conversion where it is offered: not on NVIDIA PCs.) [[ "${WANTED[single]:-0}" == 1 ]] && component_available gaming && WANTED[gaming]=1 if component_available gaming; then [[ "${WANTED[gaming]:-0}" == 0 ]] && { WANTED[single]=0; WANTED[boot]=0; } @@ -186,6 +185,12 @@ backend_apply() { if backend_run_component "$c" disable; then feature_record "$c" disable; else failed+=("$c"); fi done for c in "${TO_ENABLE[@]}"; do + # A sub-option of something that just failed isn't applied: it would be left on without its parent. + if [[ -n "${PARENT[$c]:-}" && " ${failed[*]:-} " == *" ${PARENT[$c]} "* ]]; then + backend_event log "\"id\":$(json_str "$c"),\"line\":$(json_str "Skipped: ${LABEL[${PARENT[$c]}]%%:*} didn't work.")" + failed+=("$c") + continue + fi if backend_run_component "$c" enable; then is_action "$c" || feature_record "$c" enable; else failed+=("$c"); fi done feature_record_unticked diff --git a/lib/cec.sh b/lib/cec.sh index 0171646..f221993 100644 --- a/lib/cec.sh +++ b/lib/cec.sh @@ -73,11 +73,16 @@ cec_driver_enable() { cp "$CEC_DRIVER_CACHE" "$tmp/cros-ec-cec.c" else info "Downloading Valve's Steam Machine CEC driver..." - if ! curl -fsL --retry 2 "$CEC_DRIVER_URL" -o "$tmp/cros-ec-cec.c" || + if ! curl -fsL --retry 2 --max-time 60 "$CEC_DRIVER_URL" -o "$tmp/cros-ec-cec.c" || ! echo "$CEC_DRIVER_SHA256 $tmp/cros-ec-cec.c" | sha256sum -c --quiet -; then - rm -rf "$tmp" - err "Downloading Valve's CEC driver failed (or its checksum didn't match)." - return 1 + # GitHub is down, rate-limits us, or sent something else: Steamify carries the same file (checked the same way). + warn "Downloading Valve's CEC driver failed (or its checksum didn't match): using the copy Steamify carries." + patch_file cros-ec-cec.c > "$tmp/cros-ec-cec.c" + if ! echo "$CEC_DRIVER_SHA256 $tmp/cros-ec-cec.c" | sha256sum -c --quiet -; then + rm -rf "$tmp" + err "Steamify's own copy of Valve's CEC driver doesn't match the pinned checksum (a new pin needs a new copy in patches/)." + return 1 + fi fi # The name carries the checksum: a newer pin gets its own file, the old one goes. sudo find "${CEC_DRIVER_CACHE%/*}" -maxdepth 1 -name 'cros-ec-cec-*.c' ! -name "${CEC_DRIVER_CACHE##*/}" -delete 2>/dev/null diff --git a/lib/gamescope-nvidia.sh b/lib/gamescope-nvidia.sh new file mode 100644 index 0000000..8ffea23 --- /dev/null +++ b/lib/gamescope-nvidia.sh @@ -0,0 +1,308 @@ +#!/bin/bash +# gamescope's own gaming mode on NVIDIA: a patched gamescope that allocates its scan-out buffers with GBM, which cures +# the corrupted picture (https://github.com/ValveSoftware/gamescope/issues/2309). The patch is opt-in: gamescope reads +# `gamescope_drm_gbm_scanout=1` from its environment, which is set for the gaming session (gamescope-session's +# environment file, and environment.d). +# +# gamescope changes every few weeks, so the package is not carried but downloaded: the newest release of +# https://github.com/theupriser/steamify-gamescope-nvidia (CI builds it in CachyOS's container, a person merges the +# version bump), and a newer one than the installed package shows as an update of the SteamOS conversion +# (`gsn_update_pending`). What this Steamify release was tested with is the fallback: the pinned package (sha256 below), +# a copy that came with the ISO, and last a build here from the recipe and patch in patches/. Simple files that don't change +# stay in patches/ and are used as they are. +# +# Used by the SteamOS conversion (`gaming_enable`/`gaming_disable`) on every NVIDIA PC (`nvidia_present`); the kernel +# parameters and the rest of lib/nvidia.sh's setup only on a supported card (`nvidia_supported`): GTX 16 / RTX 20 series or +# newer (Turing and later, what NVIDIA's open kernel driver supports). Tested on an RTX 5080 only. +# Sourced by steamify.sh; not meant to be run on its own. + +GSN_PKG=steamify-gamescope-nvidia +GSN_RELEASES="${GSN_RELEASES:-https://github.com/theupriser/steamify-gamescope-nvidia/releases}" +# The package this Steamify release was tested with (patches/gamescope-nvidia.PKGBUILD names the same version): the +# fallback. A new pin: the release's tag, the package's sha256, and the PKGBUILD's pkgver/pkgrel. +GSN_VERSION=3.16.31-3 +# One build per CPU level, like CachyOS's repositories: generic is the one every x86-64 PC can run. +declare -A GSN_SHA256=( + [generic]=e9c7e4d8e1f2a909c25c7e090a86e610234103c6014e65882e84bffe088bafb2 + [v3]=0a2e9408d8faea3b7c8dda8a4f8d01f7c7e77eea2374590a311856ee3feb559e + [v4]=550c08910617d6e770dfa8ebe742f11d04fa65e51dea8773ce419e1e0244572e + [znver4]=ec05ba1ba7e7cbde7c2dbee6959b30f89a96659cd4b7f675837ac425ea028d5f +) +# Where the ISO leaves a copy of the pinned package, for installs without GitHub. +GSN_BACKUP_DIR="${GSN_BACKUP_DIR:-/usr/local/share/steamify/gamescope}" +# How long a look at GitHub for a newer release counts (seconds), so opening the menu doesn't ask every time. +GSN_CHECK_EVERY="${GSN_CHECK_EVERY:-21600}" +GSN_ENV_LINE='gamescope_drm_gbm_scanout=1' +GSN_ENV_D="${GSN_ENV_D:-$HOME/.config/environment.d/99-steamify-gamescope-nvidia.conf}" +GSN_SESSION_ENV="${GSN_SESSION_ENV:-$HOME/.config/gamescope-session/environment}" + +gsn_package_installed() { pacman -Q "$GSN_PKG" >/dev/null 2>&1; } + +gsn_installed_version() { pacman -Q "$GSN_PKG" 2>/dev/null | awk '{print $2}'; } + +gsn_env_set() { [[ -f "$GSN_ENV_D" ]] && grep -qsx "$GSN_ENV_LINE" "$GSN_SESSION_ENV"; } + +gsn_status() { gsn_package_installed && gsn_env_set; } + +gsn_newer() { + # gsn_newer : version a is newer than b (pacman's own comparison). + [[ "$(vercmp "$1" "$2" 2>/dev/null)" == 1 ]] +} + +gsn_latest_tag() { + # The newest release's tag (-), from GitHub; nothing when it can't be reached or + # there is no release. Follows the redirect of /releases/latest: no API, no rate limit, no JSON. gsn_latest_tag [seconds] + local url + url="$(curl -fsL --connect-timeout 4 --max-time "${1:-10}" -o /dev/null -w '%{url_effective}' "$GSN_RELEASES/latest" 2>/dev/null)" || return 1 + url="${url##*/}" + [[ "$url" =~ ^[0-9][0-9A-Za-z._]*-[0-9]+$ ]] || return 1 + echo "$url" +} + +gsn_update_pending() { + # The patched gamescope is installed and GitHub has a newer release: the menu shows the conversion as an update. + # Looked up at most every GSN_CHECK_EVERY seconds (the answer is kept in the state), quickly, and silently when + # offline: no network is never a reason to show or hide anything. + gsn_package_installed || return 1 + local now checked latest + now="$(date +%s)"; checked="$(state_get gamescope_nvidia checked 0)" + if (( now - ${checked:-0} > GSN_CHECK_EVERY )); then + state_set gamescope_nvidia checked "$now" + latest="$(gsn_latest_tag 5)" && state_set gamescope_nvidia latest "$latest" + fi + latest="$(state_get gamescope_nvidia latest)" + [[ -n "$latest" ]] && gsn_newer "$latest" "$(gsn_installed_version)" +} + +gsn_cpu_variant() { + # The best build this CPU can run: znver4 (Ryzen 7000/9000: AMD with AVX-512 BF16), v4, v3, else generic. The + # dynamic loader knows the CPU level best ("x86-64-v4 (supported, searched)"). + local help; help="$(/lib64/ld-linux-x86-64.so.2 --help 2>/dev/null)" + if grep -q 'x86-64-v4 (supported' <<< "$help"; then + if grep -q AuthenticAMD /proc/cpuinfo && grep -qw avx512_bf16 /proc/cpuinfo; then echo znver4; else echo v4; fi + elif grep -q 'x86-64-v3 (supported' <<< "$help"; then echo v3 + else echo generic; fi +} + +gsn_sha_of() { + # The hash a release file is expected to have: the pinned one for the pinned version, else the .sha256 next to the + # package in the same release (it shows the download wasn't damaged, not who made it). gsn_sha_of + local tag="$1" file="$2" variant="$3" + if [[ "$tag" == "$GSN_VERSION" ]]; then echo "${GSN_SHA256[$variant]}"; return 0; fi + curl -fsL --connect-timeout 10 --max-time 30 "$GSN_RELEASES/download/$tag/$file.sha256" 2>/dev/null | awk 'NR==1 {print $1}' +} + +gsn_download() { + # gsn_download : the release's package, verified, in the cache; prints its path. + local tag="$1" variant="$2" cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" file want got + file="$GSN_PKG-$tag-$variant.pkg.tar.zst" + mkdir -p "$cache" || return 1 + want="$(gsn_sha_of "$tag" "$file" "$variant")" + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || return 1 + if [[ -f "$cache/$file" ]] && [[ "$(sha256sum "$cache/$file" | awk '{print $1}')" == "$want" ]]; then echo "$cache/$file"; return 0; fi + curl -fsL --retry 2 --connect-timeout 15 --max-time 300 -o "$cache/$file.part" "$GSN_RELEASES/download/$tag/$file" || { rm -f "$cache/$file.part"; return 1; } + got="$(sha256sum "$cache/$file.part" | awk '{print $1}')" + if [[ "$got" != "$want" ]]; then rm -f "$cache/$file.part"; return 1; fi + mv -f "$cache/$file.part" "$cache/$file" + echo "$cache/$file" +} + +gsn_fetch_package() { + # The package to install, as a path: the build for this CPU, then the generic one, each from the newest release on + # GitHub (not older than the pinned one), else the pinned release (GitHub, the cache, or the copy that came with the + # ISO). Only files with the expected sha256 count. gsn_fetch_package [variant] + local tag pkg v cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" file copy + local -a variants=("${1:-$(gsn_cpu_variant)}"); [[ "${variants[0]}" == generic ]] || variants+=(generic) + tag="$(gsn_latest_tag)" || tag="" + for v in "${variants[@]}"; do + if [[ -n "$tag" && "$tag" != "$GSN_VERSION" ]] && gsn_newer "$tag" "$GSN_VERSION"; then + info "Downloading the newest patched gamescope ($tag, $v)..." >&2 + if pkg="$(gsn_download "$tag" "$v")"; then echo "$pkg"; return 0; fi + warn "Downloading the newest patched gamescope ($tag, $v) failed: trying the version this Steamify was tested with ($GSN_VERSION)." >&2 + fi + info "Downloading the patched gamescope ($GSN_VERSION, $v)..." >&2 + if pkg="$(gsn_download "$GSN_VERSION" "$v")"; then echo "$pkg"; return 0; fi + file="$GSN_PKG-$GSN_VERSION-$v.pkg.tar.zst" + for copy in "$GSN_BACKUP_DIR/$file" "$cache/$file"; do + if [[ -f "$copy" ]] && [[ "$(sha256sum "$copy" | awk '{print $1}')" == "${GSN_SHA256[$v]}" ]]; then + info "Using the copy that came with Steamify: $copy" >&2 + echo "$copy"; return 0 + fi + done + done + warn "Downloading the patched gamescope failed (or its checksum didn't match)." >&2 + return 1 +} + +gsn_smoke_test() { + # The installed gamescope must start (headless, no screen needed): a build that crashes would leave gaming mode in a loop. + # It segfaults while shutting down headless (also unpatched builds), so what counts is that it ran the program. + timeout 25 gamescope --backend headless -W 640 -H 360 -r 60 -- true 2>&1 | grep -q 'Primary child shut down' +} + +gsn_build_package() { + # Last resort: builds the pinned package from the recipe and patch Steamify carries (patches/), as the user. It + # downloads gamescope's sources (Valve's repository at the PKGBUILD's pkgver, and its submodules) and compiles them: a + # few minutes. Prints the package's path. + local dir cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" + info "Building the patched gamescope here instead (a few minutes)..." >&2 + sudo pacman -S --needed --noconfirm base-devel git >&2 || { err "Installing the build tools failed." >&2; return 1; } + # On disk, not /tmp: the installer's chroot has a tmpfs there (RAM), and the build is 1-2 GB. + mkdir -p "$cache" && dir="$(mktemp -d -p "$cache" gamescope-build.XXXXXX)" || return 1 + patch_file gamescope-nvidia.PKGBUILD > "$dir/PKGBUILD" && + patch_file gamescope-gbm-scanout.patch > "$dir/gamescope-gbm-scanout.patch" && + patch_file gamescope-ui-native.patch > "$dir/gamescope-ui-native.patch" || { + err "Couldn't write the build files." >&2 + rm -rf "$dir"; return 1 + } + if ! (cd "$dir" && makepkg -sf --noconfirm --nocheck >&2); then + err "Building the patched gamescope failed (see above)." >&2 + rm -rf "$dir"; return 1 + fi + # Moved out, so the build folder can go. + local f built="" + for f in "$dir"/"$GSN_PKG"-*.pkg.tar.*; do + [[ -e "$f" && "$f" != *-debug-* ]] && { built="$f"; break; } + done + [[ -n "$built" ]] && mv "$built" "$cache/gamescope-local-build.pkg.tar.zst" || { rm -rf "$dir"; return 1; } + rm -rf "$dir" + echo "$cache/gamescope-local-build.pkg.tar.zst" +} + +gsn_install_package() { + # The patched gamescope, newest first, each step only when the one before failed: the newest release from GitHub, the + # pinned package (GitHub, cache or the copy that came with the ISO), a build here. A package that downloaded fine but + # doesn't install (its libraries differ from this PC's) goes on to the next step too. Nothing else changes before one of + # them is installed, so a failure leaves the PC as it was, with what it had. --ask 4 lets pacman replace the stock + # gamescope it conflicts with, and an older patched one. + local pkg latest have + have="$(gsn_installed_version)" + latest="$(gsn_latest_tag)" || latest="" + # Already the newest (or, offline, the pinned version or newer): nothing to do. + if [[ -n "$have" ]]; then + if [[ -n "$latest" ]] && ! gsn_newer "$latest" "$have"; then return 0; fi + if [[ -z "$latest" ]] && ! gsn_newer "$GSN_VERSION" "$have"; then return 0; fi + fi + if pkg="$(gsn_fetch_package)" && sudo pacman -U --noconfirm --ask 4 "$pkg"; then + if ! gsn_smoke_test; then + warn "The patched gamescope for this CPU doesn't start: installing the generic build instead." + pkg="$(gsn_fetch_package generic)" && sudo pacman -U --noconfirm --ask 4 "$pkg" && gsn_smoke_test || + { err "The patched gamescope doesn't start on this PC."; return 1; } + fi + ok "Patched gamescope installed." + return 0 + fi + warn "The ready-made patched gamescope couldn't be installed." + [[ -n "$have" ]] && { warn "The installed one ($have) stays."; return 0; } + if pkg="$(gsn_build_package)" && sudo pacman -U --noconfirm --ask 4 "$pkg"; then + ok "Patched gamescope built and installed." + return 0 + fi + err "The patched gamescope couldn't be installed. The stock gamescope stays." + return 1 +} + +gsn_env_enable() { + mkdir -p "$(dirname "$GSN_ENV_D")" "$(dirname "$GSN_SESSION_ENV")" + echo "$GSN_ENV_LINE" > "$GSN_ENV_D" + touch "$GSN_SESSION_ENV" + grep -qsx "$GSN_ENV_LINE" "$GSN_SESSION_ENV" || echo "$GSN_ENV_LINE" >> "$GSN_SESSION_ENV" +} + +gsn_env_disable() { + rm -f "$GSN_ENV_D" + [[ -f "$GSN_SESSION_ENV" ]] && sed -i "/^${GSN_ENV_LINE}\$/d" "$GSN_SESSION_ENV" + return 0 +} + +# --- The session starts gamescope on the display's own mode +# +# gamescope-session starts gamescope without a size or a refresh rate, so it takes the display's *preferred* mode, which +# many monitors declare as 60 Hz while the desktop runs them at their highest. patches/gamescope-display-wrapper.sh (put +# first in the gaming session's PATH by a drop-in of gamescope-session.service) starts it on the mode KDE has saved for +# the connected display, else the biggest mode at the highest refresh rate, read at every session start. +GSN_DISPLAY_WRAPPER="${GSN_DISPLAY_WRAPPER:-$STEAMIFY_BIN/gamescope}" +GSN_DISPLAY_DROPIN="${GSN_DISPLAY_DROPIN:-$HOME/.config/systemd/user/gamescope-session.service.d/steamify-display.conf}" + +gsn_display_status() { [[ -x "$GSN_DISPLAY_WRAPPER" && -f "$GSN_DISPLAY_DROPIN" ]]; } + +gsn_display_pending() { + # The patched gamescope is in use but the session still starts it on the display's preferred mode: a normal run sets it up. + gsn_package_installed && gsn_env_set && ! gsn_display_status +} + +gsn_display_enable() { + patch_file gamescope-display-wrapper.sh | install_executable "$GSN_DISPLAY_WRAPPER" 755 || return 1 + mkdir -p "$(dirname "$GSN_DISPLAY_DROPIN")" + cat > "$GSN_DISPLAY_DROPIN" << EOF +# Steamify: the gaming session starts gamescope on the display's own mode (see lib/gamescope-nvidia.sh). Turning the +# SteamOS conversion off removes this file. +[Service] +ExecStart= +ExecStart=/usr/bin/bash -c 'PATH="$(dirname "$GSN_DISPLAY_WRAPPER"):\$PATH" exec /usr/lib/steamos/gamescope-session' +EOF + user_systemctl daemon-reload +} + +gsn_display_disable() { + # Only our own files: the wrapper is recognised by its first comment line. + grep -qs "^# Steamify: gamescope-session starts gamescope" "$GSN_DISPLAY_WRAPPER" && rm -f "$GSN_DISPLAY_WRAPPER" + [[ -f "$GSN_DISPLAY_DROPIN" ]] && rm -f "$GSN_DISPLAY_DROPIN" && rmdir "$(dirname "$GSN_DISPLAY_DROPIN")" 2>/dev/null + user_systemctl daemon-reload + return 0 +} + +gsn_preflight() { + # Before anything changes: what would make the install fail half way. Prints the reason and fails, or passes. + if [[ -e /var/lib/pacman/db.lck ]]; then + echo "another package manager is running (/var/lib/pacman/db.lck): finish or close it and try again"; return 1 + fi + local free; free="$(df --output=avail -BM / 2>/dev/null | tail -1 | tr -dc 0-9)" + if [[ -n "$free" ]] && (( free < 1500 )); then + echo "less than 1.5 GB free on /: free some space and try again"; return 1 + fi + return 0 +} + +gsn_fail_mark() { + # Remember a failed setup so the menu says so on the next start, and tell the user now (desktop notification). + state_set gamescope_nvidia failed "$(date '+%Y-%m-%d %H:%M'): $1" + command -v notify-send >/dev/null 2>&1 && + notify-send -a Steamify -i dialog-warning "Steamify: gaming mode setup failed" "$1. Open Steamify and apply again." 2>/dev/null || true +} + +gsn_fail_note() { + # The menu's reminder of a failed setup (nothing when the last one worked). + local f; f="$(state_get gamescope_nvidia failed)" + [[ -n "$f" ]] && warn "The last gaming mode setup for NVIDIA failed ($f). Apply the conversion again to retry." + return 0 +} + +gsn_enable() { + # Only on a supported NVIDIA card; elsewhere the stock gamescope is right. + nvidia_present || return 0 + local why + if ! why="$(gsn_preflight)"; then err "Gaming mode for NVIDIA can't be set up: $why."; gsn_fail_mark "$why"; return 1; fi + # A PC that had "Gaming on NVIDIA" on: Steam must not also start on the desktop. + nvidia_swap_out + # What NVIDIA's DRM stack wants (lib/nvidia.sh; a supported card only, the functions check): modeset/fbdev on the + # kernel command line, the modules in the initramfs, video memory kept over suspend, the 32-bit libraries. Takes + # effect at the next boot, like the session. + nvidia_kernel_enable || return 1 + nvidia_extras_enable || return 1 + gsn_install_package || { gsn_fail_mark "the patched gamescope couldn't be installed"; return 1; } + state_set gamescope_nvidia failed "" + gsn_env_enable + gsn_display_enable || warn "Couldn't set up the display mode for gaming mode: gamescope takes the display's preferred mode." + ok "gamescope draws NVIDIA's picture through GBM (gamescope_drm_gbm_scanout=1) from the next gaming mode start." +} + +gsn_disable() { + # The setting goes, and what nvidia_*_enable added; the packages stay (patched gamescope, 32-bit libraries), like + # the other packages of the conversion (steam, gamescope-session-cachyos). + gsn_env_disable + gsn_display_disable + nvidia_present || return 0 + nvidia_extras_disable + nvidia_kernel_disable +} diff --git a/lib/login-manager.sh b/lib/login-manager.sh index 7cef57f..c8ec2a1 100644 --- a/lib/login-manager.sh +++ b/lib/login-manager.sh @@ -94,6 +94,8 @@ gaming_enable() { # LOGIN_MANAGER (sddm with single user, else plasmalogin) is set by the # menu. Re-running with the other value switches over cleanly. install_required_packages || return 1 + # NVIDIA: gamescope's picture is corrupted unless it is the patched one (no-op elsewhere). + gsn_enable || return 1 case "$LOGIN_MANAGER" in sddm) switch_to_sddm || return 1 @@ -107,6 +109,8 @@ gaming_enable() { install_session_sync ;; esac + # (NVIDIA with single user mode from before the conversion: its desktop autologin would pin the Plasma session.) + single_login_release create_desktop_shortcut steam_enable os_name_enable || return 1 @@ -122,8 +126,11 @@ gaming_disable() { switch_to_plasmalogin remove_desktop_shortcut steam_disable + gsn_disable krevert gaming os_name_disable + # NVIDIA with single user mode still on: nothing else logs in by itself any more. + nvidia_present && single_status && single_login_enable ok "The PC boots to the normal login screen again (from the next boot)." } diff --git a/lib/menu.sh b/lib/menu.sh index 20a01b5..8937fd8 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -26,7 +26,7 @@ NO_PRESELECT=(boot silent nvsilent cec kpin hdmi extended_controller_support) # FEATURE_BASELINE. FEATURE_BASELINE=2.1.0 declare -A FEATURE_VERSION=( - [gaming]=2.9.0 [boot]=2.1.0 [silent]=2.11.0 [nvidia]=2.10.0 [bigpicture]=2.10.0 [nvsilent]=2.11.0 [theme]=2.1.0 [glyphs]=2.1.0 [single]=2.1.0 + [gaming]=2.9.0 [boot]=2.1.0 [silent]=2.11.0 [nvidia]=2.12.0 [bigpicture]=2.10.0 [nvsilent]=2.11.0 [theme]=2.1.0 [glyphs]=2.1.0 [single]=2.1.0 [launcher]=2.1.0 [cec]=2.7.0 [machine]=2.9.0 [poweroff]=2.2.0 [vram]=2.3.0 [notify]=2.5.0 [steamgame]=2.5.1 [extended_controller_support]=2.10.0 [kpin]=2.1.0 [hdmi]=2.1.0 ) @@ -57,11 +57,9 @@ declare -A CURRENT WANTED component_available() { case "$1" in - # gamescope's own session is broken on NVIDIA: the conversion is replaced by "Gaming on NVIDIA" there (shown - # anyway when it's already on, so it can be turned off). - gaming|boot|silent|glyphs) ! nvidia_present || gaming_status ;; - nvidia) nvidia_available ;; - bigpicture|nvsilent) bigpicture_available ;; + # The SteamOS conversion is offered on NVIDIA PCs too: it installs the patched gamescope there (lib/gamescope-nvidia.sh) + # and turns off "Gaming on NVIDIA" (lib/nvidia.sh), which is not offered any more (nvidia_available). + nvidia|bigpicture|nvsilent) nvidia_available ;; machine|poweroff) machine_available ;; vram) vram_available ;; # Steamify in the Steam library is for gaming mode's controller: not needed for Big Picture on the desktop (NVIDIA PCs; @@ -115,7 +113,11 @@ feature_outdated() { # On, but set up by an older version of that feature (compared as # versions: 2.10.0 is newer than 2.9.0). local have want + # "Gaming on NVIDIA" is on: the SteamOS conversion replaces it, as an update of what the PC has (lib/nvidia.sh). + [[ "$1" == gaming ]] && nvidia_migration_pending && return 0 [[ "${CURRENT[$1]:-0}" == 1 ]] || return 1 + # NVIDIA: a newer patched gamescope is out (lib/gamescope-nvidia.sh): a normal run installs it. + [[ "$1" == gaming ]] && nvidia_present && { gsn_update_pending || gsn_display_pending; } && return 0 have="$(state_get features "$1" "$FEATURE_BASELINE")" [[ "$have" == off ]] && have="$FEATURE_BASELINE" want="${FEATURE_VERSION[$1]:-$FEATURE_BASELINE}" @@ -184,6 +186,17 @@ detect_components() { component_available "$c" || continue { feature_outdated "$c" || feature_new "$c"; } && WANTED[$c]=1 done + # "Gaming on NVIDIA" was on: its replacement, the SteamOS conversion, comes with the defaults of a first run, and the + # PC starts the way it did: Steam in Big Picture at login becomes booting into gaming mode, any other Steam start + # (a normal window, or silent in the tray) becomes booting into the desktop, with the silent start kept. + if nvidia_migration_pending; then + WANTED[glyphs]=1 + if bigpicture_status; then WANTED[boot]=0 + else + WANTED[boot]=1 + nvsilent_status && WANTED[silent]=1 + fi + fi # The kernel pin is no longer needed (the power-off fix), and HDMI # refresh boost needed the pin: whatever is left of either is unticked, # so a normal run removes it. @@ -296,7 +309,6 @@ toggle_component() { component_selectable "$c" || return 1 WANTED[$c]=$(( 1 - WANTED[$c] )) # Single user mode only makes sense on top of the SteamOS conversion. - # (On NVIDIA the conversion isn't offered: single user mode logs in by itself, see lib/single-user.sh.) if [[ "$c" == single && "${WANTED[single]}" == 1 ]] && component_available gaming; then WANTED[gaming]=1; fi if [[ "$c" == gaming && "${WANTED[gaming]}" == 0 ]]; then WANTED[single]=0; WANTED[boot]=0; fi # Where to boot to is part of the conversion, too. @@ -325,6 +337,7 @@ toggle_component() { show_menu() { local i=0 c now want echo + gsn_fail_note printf " ${c_bold}%-3s %-6s %-6s %s${c_reset}\n" "#" "Now" "Want" "Component" MENU_ITEMS=() for c in "${COMPONENTS[@]}"; do @@ -499,6 +512,11 @@ apply_changes() { if "${c}_disable"; then feature_record "$c" disable; else failed+=("$c"); fi done for c in "${TO_ENABLE[@]}"; do + # A sub-option of something that just failed isn't applied: it would be left on without its parent. + if [[ -n "${PARENT[$c]:-}" && " ${failed[*]:-} " == *" ${PARENT[$c]} "* ]]; then + warn "Skipping ${LABEL[$c]%%:*}: ${LABEL[${PARENT[$c]}]%%:*} didn't work." + continue + fi if [[ "$c" == boot ]]; then echo; echo -e "${c_bold}Boot into: desktop${c_reset}" elif is_action "$c"; then echo; echo -e "${c_bold}Running: ${LABEL[$c]%%:*}${c_reset}" else echo; echo -e "${c_bold}Turning on: ${LABEL[$c]}${c_reset}"; fi diff --git a/lib/nvidia.sh b/lib/nvidia.sh index 7bc10f7..d7640db 100644 --- a/lib/nvidia.sh +++ b/lib/nvidia.sh @@ -13,6 +13,11 @@ NVIDIA_INITRAMFS_CONF=${NVIDIA_INITRAMFS_CONF:-/etc/mkinitcpio.conf.d/90-steamif # The script and pacman hook that keep the initramfs file right at every kernel change. NVIDIA_SCRIPT=${NVIDIA_SCRIPT:-/usr/local/libexec/steamify-nvidia-initramfs} NVIDIA_HOOK=${NVIDIA_HOOK:-/etc/pacman.d/hooks/85-steamify-nvidia-initramfs.hook} +# Keeps video memory over suspend (with the nvidia-suspend/resume services). +NVIDIA_MODPROBE=${NVIDIA_MODPROBE:-/etc/modprobe.d/90-steamify-nvidia.conf} +NVIDIA_SLEEP_UNITS="nvidia-suspend.service nvidia-resume.service nvidia-hibernate.service" +# 32-bit parts Steam games and the 32-bit MangoHud preload need. +NVIDIA_LIB32_PKGS="lib32-nvidia-utils lib32-libxkbcommon" # The running kernel's command line; the tests point this at a file. NVIDIA_CMDLINE=${NVIDIA_CMDLINE:-/proc/cmdline} # The DRM devices in sysfs; the tests point this at a fake tree. @@ -32,9 +37,27 @@ nvidia_present() { return 1 } -# Shown on NVIDIA PCs, unless the SteamOS conversion is already on there (it -# has to be turned off first: both would start Steam at login). -nvidia_available() { nvidia_present && ! gaming_status 2>/dev/null; } +# Not offered any more (2.12.0): the SteamOS conversion with the patched gamescope (lib/gamescope-nvidia.sh) covers NVIDIA PCs. +# A PC that has this on keeps it until the conversion is turned on, which calls nvidia_swap_out. +nvidia_available() { return 1; } + +nvidia_migration_pending() { + # "Gaming on NVIDIA" is on (set up before 2.12.0) and the SteamOS conversion that replaces it isn't: the menu ticks the + # conversion and shows it as an update, so a normal run moves the PC over (nvidia_swap_out in gsn_enable). + nvidia_present && nvidia_status && ! gaming_status 2>/dev/null +} + +nvidia_swap_out() { + # The conversion takes over: Steam no longer starts by itself on the desktop at login (both would start it). Steam, the + # packages and the NVIDIA settings stay: the conversion needs them too. The state stays for the conversion's own disable. + nvidia_status || return 0 + info "Gaming on NVIDIA is replaced by the SteamOS conversion: Steam no longer starts on the desktop at login." + user_systemctl disable --now "$NVIDIA_UNIT" >/dev/null 2>&1 + rm -f "$(nvidia_unit_file)" + user_systemctl daemon-reload + krevert bigpicture + return 0 +} bigpicture_available() { nvidia_available; } nvidia_status() { @@ -62,6 +85,7 @@ nvidia_enable() { nvidia_write_unit "$unit_args" || return 1 user_systemctl enable "$NVIDIA_UNIT" >/dev/null 2>&1 || { err "Enabling $NVIDIA_UNIT failed."; return 1; } nvidia_kernel_enable || return 1 + nvidia_extras_enable || return 1 ok "Steam starts at your next login." } @@ -70,13 +94,19 @@ nvidia_disable() { user_systemctl disable --now "$NVIDIA_UNIT" >/dev/null 2>&1 rm -f "$unit" user_systemctl daemon-reload + # Swapped for the SteamOS conversion (turned on first, in the same run): it needs the same settings, they stay. + gaming_status 2>/dev/null || nvidia_extras_disable pkgs="$(state_get nvidia installed_pkgs)" + if gaming_status 2>/dev/null; then + local p; pkgs=" $pkgs " + for p in $NVIDIA_LIB32_PKGS; do pkgs="${pkgs// $p / }"; done + fi # Only what Steamify installed; games and the Steam folder stay. # shellcheck disable=SC2086 # a list of package names [[ -n "$pkgs" ]] && sudo pacman -Rns --noconfirm $pkgs >/dev/null 2>&1 state_clear nvidia krevert bigpicture - nvidia_kernel_disable + gaming_status 2>/dev/null || nvidia_kernel_disable ok "Steam no longer starts at login." } @@ -118,7 +148,7 @@ nvsilent_disable() { nvidia_supported() { # A supported NVIDIA GPU: PCI vendor 0x10de, display class, its kernel - # driver installed, and not older than the RTX 20 series, decided like the + # driver installed, and not older than Turing (GTX 16 / RTX 20 series: the cards NVIDIA's open kernel driver supports), decided like the # VRAM booster does (vram_nvidia_legacy_id: chwd's legacy card lists, every # other card runs NVIDIA's open modules). Older cards are left alone. local d @@ -254,6 +284,48 @@ nvidia_kernel_enable() { return 0 } +nvidia_extras_enable() { + # Video memory kept over sleep, the matching services and the 32-bit libraries (a supported card only). + nvidia_supported || return 0 + local pkg u units="" prev missing=() + if [[ ! -f "$NVIDIA_MODPROBE" ]]; then + info "Keeping video memory over suspend (NVreg_PreserveVideoMemoryAllocations=1)..." + printf '%s\n' '# Added by Steamify (Gaming on NVIDIA)' 'options nvidia NVreg_PreserveVideoMemoryAllocations=1' | + sudo tee "$NVIDIA_MODPROBE" >/dev/null || { err "Writing $NVIDIA_MODPROBE failed."; return 1; } + # The NVIDIA modules load from the initramfs, which carries modprobe.d. + nvidia_rebuild_boot || warn "Rebuilding the initramfs failed: the suspend setting applies once it is rebuilt." + fi + for u in $NVIDIA_SLEEP_UNITS; do + systemctl is-enabled -q "$u" 2>/dev/null && continue + sudo systemctl enable "$u" >/dev/null 2>&1 && units+="${units:+ }$u" + done + if [[ -n "$units" ]]; then + prev="$(state_get nvidia enabled_units)" + state_set nvidia enabled_units "${prev:+$prev }$units" + fi + for pkg in $NVIDIA_LIB32_PKGS; do pacman -Q "$pkg" >/dev/null 2>&1 || missing+=("$pkg"); done + if [[ ${#missing[@]} -gt 0 ]]; then + info "Installing the 32-bit libraries games need: ${missing[*]}" + if sudo pacman -S --needed --noconfirm "${missing[@]}"; then + prev="$(state_get nvidia installed_pkgs)" + state_set nvidia installed_pkgs "${prev:+$prev }${missing[*]}" + else + warn "Installing ${missing[*]} failed (is [multilib] enabled in /etc/pacman.conf?): 32-bit games may not start." + fi + fi + return 0 +} + +nvidia_extras_disable() { + # Only what Steamify enabled; the 32-bit packages go with installed_pkgs. + local u + for u in $(state_get nvidia enabled_units); do sudo systemctl disable "$u" >/dev/null 2>&1; done + if [[ -f "$NVIDIA_MODPROBE" ]]; then + sudo rm -f "$NVIDIA_MODPROBE" + nvidia_rebuild_boot || true + fi +} + nvidia_kernel_disable() { local f changed=0; f="$(nvidia_boot_file)" if [[ -n "$f" ]] && grep -q "$NVIDIA_PARAMS" "$f" 2>/dev/null; then diff --git a/lib/packages.sh b/lib/packages.sh index 14164d0..d16a45e 100644 --- a/lib/packages.sh +++ b/lib/packages.sh @@ -58,6 +58,30 @@ bootstrap_yay() { fi } +python_hid_shim_ensure() { + # jupiter-hw-support (a dependency of gamescope-session-cachyos) wants python-hid, which conflicts with python-hidapi, and + # a PC can need python-hidapi (liquidctl does). Only Valve's Steam Deck controller firmware updater uses python-hid, which + # a PC doesn't run: so a placeholder package that provides python-hid keeps python-hidapi and satisfies the dependency + # (also at later updates, which a one-time --assume-installed wouldn't). It stays when the conversion goes off: + # jupiter-hw-support, which stays, depends on it. + pacman -Q python-hidapi >/dev/null 2>&1 || return 0 + pacman -Q python-hid steamify-python-hid-shim >/dev/null 2>&1 && return 0 + local dir pkg cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" + warn "python-hidapi is installed (liquidctl and others need it) and conflicts with python-hid, which Steam Deck's tools ask for: keeping python-hidapi, with a small placeholder package for python-hid." + sudo pacman -S --needed --noconfirm base-devel >/dev/null || { err "Installing the build tools failed."; return 1; } + mkdir -p "$cache" && dir="$(mktemp -d -p "$cache" hid-shim.XXXXXX)" || return 1 + patch_file python-hid-shim.PKGBUILD > "$dir/PKGBUILD" || { rm -rf "$dir"; return 1; } + if ! (cd "$dir" && makepkg -f --noconfirm >/dev/null) || + ! pkg="$(ls "$dir"/steamify-python-hid-shim-*.pkg.tar.* 2>/dev/null | head -1)" || + ! sudo pacman -U --noconfirm "$pkg"; then + rm -rf "$dir" + err "Couldn't set up the python-hid placeholder: python-hidapi and python-hid conflict, so the packages can't be installed." + return 1 + fi + rm -rf "$dir" + ok "python-hidapi kept; python-hid is covered by a placeholder." +} + install_required_packages() { # Step 2: gamescope/Steam and the desktop helpers the rest of the setup uses. local -a MISSING @@ -65,6 +89,7 @@ install_required_packages() { mapfile -t MISSING < <(pacman -T gamescope-session-cachyos steam mangohud xterm ttf-liberation wqy-zenhei plasma-keyboard) if [[ ${#MISSING[@]} -gt 0 ]]; then + python_hid_shim_ensure || return 1 info "Installing missing packages: ${MISSING[*]}" sudo pacman -S --needed --noconfirm "${MISSING[@]}" || { err "Package installation failed."; return 1; } else diff --git a/lib/single-user.sh b/lib/single-user.sh index 2adc95c..6087cc8 100644 --- a/lib/single-user.sh +++ b/lib/single-user.sh @@ -140,6 +140,15 @@ EOF remove_sddm_base_autologin } +single_login_release() { + # The SteamOS conversion logs in by itself, and picks the session (gamescope, or the desktop with "Boot into"): the + # desktop autologin above, which sorts last, would pin the Plasma session and "Return to Gaming Mode" would open KDE + # again. Called when the conversion is turned on; turning it off sets it up again (gaming_disable). + [[ -f "$SINGLE_AUTOLOGIN" ]] || return 0 + info "The SteamOS conversion logs in by itself: removing the desktop autologin of single user mode." + sudo rm -f "$SINGLE_AUTOLOGIN" +} + single_login_disable() { [[ -f "$SINGLE_AUTOLOGIN" ]] || return 0 sudo rm -f "$SINGLE_AUTOLOGIN" diff --git a/patches/README.md b/patches/README.md index b5491af..5df96a2 100644 --- a/patches/README.md +++ b/patches/README.md @@ -16,3 +16,9 @@ read and reviewed on their own. `lib/*.sh` reads them with `patch_file | `steamify-os-release.sh`, `steamify-os-release.hook` | `lib/login-manager.sh` (SteamOS conversion) | What Steam's System settings show besides the OS name (which stays CachyOS's): `VERSION_ID=steamos-X.Y`, `VERSION_CODENAME=steam-machine`, `VARIANT`/`VARIANT_ID` Steamify with its version, in `/etc/os-release`; the pacman hook runs it again after cachyos-hooks (`fill SCRIPT=... VERSION=... STEAMOS=...`) | | `steamify-nvidia-initramfs.sh`, `steamify-nvidia-initramfs.hook` | `lib/nvidia.sh` (SteamOS conversion, NVIDIA fix) | The pacman hook (before the initramfs is built, after DKMS) and its script: the NVIDIA modules go into the initramfs only while every installed kernel has them, decided again at every kernel or driver change (`fill SCRIPT=...`) | | `cros-ec-cec-single-port.patch` | `lib/cec.sh` (HDMI-CEC) | Makes Valve's Steam Machine CEC driver find amdgpu's HDMI port | +| `gamescope-gbm-scanout.patch` | `lib/gamescope-nvidia.sh` (gamescope on NVIDIA) | gamescope 3.16.31 patch: scan-out buffers allocated with GBM (opt-in, `gamescope_drm_gbm_scanout=1`), which cures the corrupted picture on NVIDIA; from farnoy/gamescope, see the header of the file | +| `gamescope-nvidia.PKGBUILD` | `lib/gamescope-nvidia.sh` (gamescope on NVIDIA) | The recipe for the patched gamescope package (CI builds it per CPU level; here it is only the local-build fallback) (`steamify-gamescope-nvidia`, provides and conflicts with `gamescope`): Valve's gamescope at `pkgver` plus `gamescope-gbm-scanout.patch`; written with the patch to a temporary folder and built with `makepkg` | +| `cros-ec-cec.c` | `lib/cec.sh` (HDMI-CEC) | Valve's Steam Machine CEC driver (GPL-2.0+, from evlaV/linux-integration at the pinned commit): the backup when downloading it from GitHub fails; used only if its sha256 equals `CEC_DRIVER_SHA256` (a new pin needs a new copy) | +| `python-hid-shim.PKGBUILD` | `lib/packages.sh` (required packages) | An empty package that provides `python-hid`, for PCs that keep `python-hidapi` (they conflict; jupiter-hw-support, a dependency of gamescope-session-cachyos, wants python-hid, only the Steam Deck controller updater uses it): built with `makepkg` and installed before the conversion's packages | +| `gamescope-ui-native.patch` | `lib/gamescope-nvidia.sh` (gamescope on NVIDIA) | Small patch after the GBM one: keeps Steam's UI X server at the display's size when Steam (with `-steamos3`) asks for 1920x1080; `gamescope_xwayland_ui_native=0` turns it off | +| `gamescope-display-wrapper.sh` | `lib/gamescope-nvidia.sh` (gamescope on NVIDIA) | Installed as `gamescope` in Steamify's bin folder and put first in the gaming session's PATH: starts gamescope on the mode KDE has saved for the connected display, else the biggest mode at the highest refresh rate (modetest + `kwinoutputconfig.json`) | diff --git a/patches/cros-ec-cec.c b/patches/cros-ec-cec.c new file mode 100644 index 0000000..a149619 --- /dev/null +++ b/patches/cros-ec-cec.c @@ -0,0 +1,621 @@ +// SPDX-License-Identifier: GPL-2.0+ +/* + * CEC driver for ChromeOS Embedded Controller + * + * Copyright (c) 2018 BayLibre, SAS + * Author: Neil Armstrong + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DRV_NAME "cros-ec-cec" + +/** + * struct cros_ec_cec_port - Driver data for a single EC CEC port + * + * @port_num: port number + * @adap: CEC adapter + * @notify: CEC notifier pointer + * @rx_msg: storage for a received message + * @cros_ec_cec: pointer to the parent struct + */ +struct cros_ec_cec_port { + int port_num; + struct cec_adapter *adap; + struct cec_notifier *notify; + struct cec_msg rx_msg; + struct cros_ec_cec *cros_ec_cec; +}; + +/** + * struct cros_ec_cec - Driver data for EC CEC + * + * @cros_ec: Pointer to EC device + * @notifier: Notifier info for responding to EC events + * @write_cmd_version: Highest supported version of EC_CMD_CEC_WRITE_MSG. + * @num_ports: Number of CEC ports + * @ports: Array of ports + */ +struct cros_ec_cec { + struct cros_ec_device *cros_ec; + struct notifier_block notifier; + int write_cmd_version; + int num_ports; + struct cros_ec_cec_port *ports[EC_CEC_MAX_PORTS]; +}; + +static void cros_ec_cec_received_message(struct cros_ec_cec_port *port, + uint8_t *msg, uint8_t len) +{ + if (len > CEC_MAX_MSG_SIZE) + len = CEC_MAX_MSG_SIZE; + + port->rx_msg.len = len; + memcpy(port->rx_msg.msg, msg, len); + + cec_received_msg(port->adap, &port->rx_msg); +} + +static void handle_cec_message(struct cros_ec_cec *cros_ec_cec) +{ + struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; + uint8_t *cec_message = cros_ec->event_data.data.cec_message; + unsigned int len = cros_ec->event_size; + struct cros_ec_cec_port *port; + /* + * There are two ways of receiving CEC messages: + * 1. Old EC firmware which only supports one port sends the data in a + * cec_message MKBP event. + * 2. New EC firmware which supports multiple ports uses + * EC_MKBP_CEC_HAVE_DATA to notify that data is ready and + * EC_CMD_CEC_READ_MSG to read it. + * Check that the EC only has one CEC port, and then we can assume the + * message is from port 0. + */ + if (cros_ec_cec->num_ports != 1) { + dev_err(cros_ec->dev, + "received cec_message on device with %d ports\n", + cros_ec_cec->num_ports); + return; + } + port = cros_ec_cec->ports[0]; + + cros_ec_cec_received_message(port, cec_message, len); +} + +static void cros_ec_cec_read_message(struct cros_ec_cec_port *port) +{ + struct cros_ec_device *cros_ec = port->cros_ec_cec->cros_ec; + struct ec_params_cec_read params = { + .port = port->port_num, + }; + struct ec_response_cec_read response; + int ret = 0; + int count = 0; + + /* NOTE read as many messages as we can, return value from cros_ec_cmd + * here should either equal to the bytes transferred or a negative + * error code, so as long as we get positive integers we are + * reading messages! + */ + do { + ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_READ_MSG, ¶ms, + sizeof(params), &response, sizeof(response)); + if (ret > 0) { + ++count; + cros_ec_cec_received_message(port, response.msg, response.msg_len); + } + } + while (ret > 0); + + if (ret < 0 && count == 0) { + dev_err(cros_ec->dev, + "error reading CEC message on EC: %d\n", ret); + } +} + +static void handle_cec_event(struct cros_ec_cec *cros_ec_cec) +{ + struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; + uint32_t cec_events = cros_ec->event_data.data.cec_events; + uint32_t port_num = EC_MKBP_EVENT_CEC_GET_PORT(cec_events); + uint32_t events = EC_MKBP_EVENT_CEC_GET_EVENTS(cec_events); + struct cros_ec_cec_port *port; + + if (port_num >= cros_ec_cec->num_ports) { + dev_err(cros_ec->dev, + "received CEC event for invalid port %d\n", port_num); + return; + } + port = cros_ec_cec->ports[port_num]; + + if (events & EC_MKBP_CEC_SEND_OK) + cec_transmit_attempt_done(port->adap, CEC_TX_STATUS_OK); + + /* FW takes care of all retries, tell core to avoid more retries */ + if (events & EC_MKBP_CEC_SEND_FAILED) + cec_transmit_attempt_done(port->adap, + CEC_TX_STATUS_MAX_RETRIES | + CEC_TX_STATUS_NACK); + + if (events & EC_MKBP_CEC_HAVE_DATA) + cros_ec_cec_read_message(port); +} + +static int cros_ec_cec_event(struct notifier_block *nb, + unsigned long queued_during_suspend, + void *_notify) +{ + struct cros_ec_cec *cros_ec_cec; + struct cros_ec_device *cros_ec; + + cros_ec_cec = container_of(nb, struct cros_ec_cec, notifier); + cros_ec = cros_ec_cec->cros_ec; + + if (cros_ec->event_data.event_type == EC_MKBP_EVENT_CEC_EVENT) { + handle_cec_event(cros_ec_cec); + return NOTIFY_OK; + } + + if (cros_ec->event_data.event_type == EC_MKBP_EVENT_CEC_MESSAGE) { + handle_cec_message(cros_ec_cec); + return NOTIFY_OK; + } + + return NOTIFY_DONE; +} + +static int cros_ec_cec_set_log_addr(struct cec_adapter *adap, u8 logical_addr) +{ + struct cros_ec_cec_port *port = adap->priv; + struct cros_ec_cec *cros_ec_cec = port->cros_ec_cec; + struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; + struct ec_params_cec_set params = { + .cmd = CEC_CMD_LOGICAL_ADDRESS, + .port = port->port_num, + .val = logical_addr, + }; + int ret; + + ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_SET, ¶ms, sizeof(params), + NULL, 0); + if (ret < 0) { + dev_err(cros_ec->dev, + "error setting CEC logical address on EC: %d\n", ret); + return ret; + } + + return 0; +} + +static int cros_ec_cec_transmit(struct cec_adapter *adap, u8 attempts, + u32 signal_free_time, struct cec_msg *cec_msg) +{ + struct cros_ec_cec_port *port = adap->priv; + struct cros_ec_cec *cros_ec_cec = port->cros_ec_cec; + struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; + struct ec_params_cec_write params; + struct ec_params_cec_write_v1 params_v1; + int ret; + + if (cros_ec_cec->write_cmd_version == 0) { + memcpy(params.msg, cec_msg->msg, cec_msg->len); + ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_WRITE_MSG, ¶ms, + cec_msg->len, NULL, 0); + } else { + params_v1.port = port->port_num; + params_v1.msg_len = cec_msg->len; + memcpy(params_v1.msg, cec_msg->msg, cec_msg->len); + ret = cros_ec_cmd(cros_ec, cros_ec_cec->write_cmd_version, + EC_CMD_CEC_WRITE_MSG, ¶ms_v1, + sizeof(params_v1), NULL, 0); + } + + if (ret < 0) { + dev_err(cros_ec->dev, + "error writing CEC msg on EC: %d\n", ret); + return ret; + } + + return 0; +} + +static int cros_ec_cec_adap_enable(struct cec_adapter *adap, bool enable) +{ + struct cros_ec_cec_port *port = adap->priv; + struct cros_ec_cec *cros_ec_cec = port->cros_ec_cec; + struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; + struct ec_params_cec_set params = { + .cmd = CEC_CMD_ENABLE, + .port = port->port_num, + .val = enable, + }; + int ret; + + ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_SET, ¶ms, sizeof(params), + NULL, 0); + if (ret < 0) { + dev_err(cros_ec->dev, + "error %sabling CEC on EC: %d\n", + (enable ? "en" : "dis"), ret); + return ret; + } + + return 0; +} + +static const struct cec_adap_ops cros_ec_cec_ops = { + .adap_enable = cros_ec_cec_adap_enable, + .adap_log_addr = cros_ec_cec_set_log_addr, + .adap_transmit = cros_ec_cec_transmit, +}; + +#ifdef CONFIG_PM_SLEEP +static int cros_ec_cec_suspend(struct device *dev) +{ + struct platform_device *pdev = to_platform_device(dev); + struct cros_ec_cec *cros_ec_cec = dev_get_drvdata(&pdev->dev); + + if (device_may_wakeup(dev)) + enable_irq_wake(cros_ec_cec->cros_ec->irq); + + return 0; +} + +static int cros_ec_cec_resume(struct device *dev) +{ + struct platform_device *pdev = to_platform_device(dev); + struct cros_ec_cec *cros_ec_cec = dev_get_drvdata(&pdev->dev); + + if (device_may_wakeup(dev)) + disable_irq_wake(cros_ec_cec->cros_ec->irq); + + return 0; +} +#endif + +static SIMPLE_DEV_PM_OPS(cros_ec_cec_pm_ops, + cros_ec_cec_suspend, cros_ec_cec_resume); + +#if IS_ENABLED(CONFIG_PCI) && IS_ENABLED(CONFIG_DMI) + +/* + * Specify the DRM device name handling the HDMI output and the HDMI connector + * corresponding to each CEC port. The order of connectors must match the order + * in the EC (first connector is EC port 0, ...), and the number of connectors + * must match the number of ports in the EC (which can be queried using the + * EC_CMD_CEC_PORT_COUNT host command). + */ + +struct cec_dmi_match { + const char *sys_vendor; + const char *product_name; + const char *devname; + const char *const *conns; +}; + +static const char *const port_b_conns[] = { "Port B", NULL }; +static const char *const port_c_conns[] = { "Port C", NULL }; +static const char *const port_db_conns[] = { "Port D", "Port B", NULL }; +static const char *const port_ba_conns[] = { "Port B", "Port A", NULL }; +static const char *const port_ab_conns[] = { "Port A", "Port B", NULL }; +static const char *const port_d_conns[] = { "Port D", NULL }; + +static const struct cec_dmi_match cec_dmi_match_table[] = { + /* AMD Lilac */ + { "AMD", "Lilac", "0000:03:00.0", port_c_conns }, + /* Valve Fremont */ + { "OEM", "F7F", "0000:03:00.0", port_c_conns }, + { "Valve", "Fremont", "0000:03:00.0", port_c_conns }, + /* Google Fizz */ + { "Google", "Fizz", "0000:00:02.0", port_b_conns }, + /* Google Brask */ + { "Google", "Brask", "0000:00:02.0", port_b_conns }, + /* Google Moli */ + { "Google", "Moli", "0000:00:02.0", port_b_conns }, + /* Google Kinox */ + { "Google", "Kinox", "0000:00:02.0", port_b_conns }, + /* Google Kuldax */ + { "Google", "Kuldax", "0000:00:02.0", port_b_conns }, + /* Google Aurash */ + { "Google", "Aurash", "0000:00:02.0", port_b_conns }, + /* Google Gladios */ + { "Google", "Gladios", "0000:00:02.0", port_b_conns }, + /* Google Lisbon */ + { "Google", "Lisbon", "0000:00:02.0", port_b_conns }, + /* Google Dibbi */ + { "Google", "Dibbi", "0000:00:02.0", port_db_conns }, + /* Google Constitution */ + { "Google", "Constitution", "0000:00:02.0", port_ba_conns }, + /* Google Boxy */ + { "Google", "Boxy", "0000:00:02.0", port_d_conns }, + /* Google Taranza */ + { "Google", "Taranza", "0000:00:02.0", port_db_conns }, + /* Google Dexi */ + { "Google", "Dexi", "0000:00:02.0", port_db_conns }, + /* Google Dita */ + { "Google", "Dita", "0000:00:02.0", port_db_conns }, + /* Google Dirks */ + { "Google", "Dirks", "0000:00:02.0", port_ab_conns }, + /* Google Moxie */ + { "Google", "Moxie", "0000:00:02.0", port_b_conns }, + /* AMD Lilac */ + { "AMD", "Lilac", "0000:06:00.0", port_c_conns }, +}; + +static struct device *cros_ec_cec_find_hdmi_dev(struct device *dev, + const char * const **conns) +{ + int i; + + for (i = 0 ; i < ARRAY_SIZE(cec_dmi_match_table) ; ++i) { + const struct cec_dmi_match *m = &cec_dmi_match_table[i]; + + if (dmi_match(DMI_SYS_VENDOR, m->sys_vendor) && + dmi_match(DMI_PRODUCT_NAME, m->product_name)) { + struct device *d; + + /* Find the device, bail out if not yet registered */ + d = bus_find_device_by_name(&pci_bus_type, NULL, + m->devname); + if (!d) + return ERR_PTR(-EPROBE_DEFER); + put_device(d); + *conns = m->conns; + return d; + } + } + + /* Hardware support must be added in the cec_dmi_match_table */ + dev_warn(dev, "CEC notifier not configured for this hardware\n"); + + return ERR_PTR(-ENODEV); +} + +#else + +static struct device *cros_ec_cec_find_hdmi_dev(struct device *dev, + const char * const **conns) +{ + return ERR_PTR(-ENODEV); +} + +#endif + +static int cros_ec_cec_get_num_ports(struct cros_ec_cec *cros_ec_cec) +{ + struct ec_response_cec_port_count response; + int ret; + + ret = cros_ec_cmd(cros_ec_cec->cros_ec, 0, EC_CMD_CEC_PORT_COUNT, NULL, + 0, &response, sizeof(response)); + if (ret < 0) { + /* + * Old EC firmware only supports one port and does not support + * the port count command, so fall back to assuming one port. + */ + cros_ec_cec->num_ports = 1; + return 0; + } + + if (response.port_count == 0) { + dev_err(cros_ec_cec->cros_ec->dev, + "EC reports 0 CEC ports\n"); + return -ENODEV; + } + + if (response.port_count > EC_CEC_MAX_PORTS) { + dev_err(cros_ec_cec->cros_ec->dev, + "EC reports too many ports: %d\n", response.port_count); + return -EINVAL; + } + + cros_ec_cec->num_ports = response.port_count; + return 0; +} + +static int cros_ec_cec_get_write_cmd_version(struct cros_ec_cec *cros_ec_cec) +{ + struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; + struct ec_params_get_cmd_versions_v1 params = { + .cmd = EC_CMD_CEC_WRITE_MSG, + }; + struct ec_response_get_cmd_versions response; + int ret; + + ret = cros_ec_cmd(cros_ec, 1, EC_CMD_GET_CMD_VERSIONS, ¶ms, + sizeof(params), &response, sizeof(response)); + if (ret < 0) { + dev_err(cros_ec->dev, + "error getting CEC write command version: %d\n", ret); + return ret; + } + + if (response.version_mask & EC_VER_MASK(1)) { + cros_ec_cec->write_cmd_version = 1; + } else { + if (cros_ec_cec->num_ports != 1) { + dev_err(cros_ec->dev, + "v0 write command only supports 1 port, %d reported\n", + cros_ec_cec->num_ports); + return -EINVAL; + } + cros_ec_cec->write_cmd_version = 0; + } + + return 0; +} + +static int cros_ec_cec_init_port(struct device *dev, + struct cros_ec_cec *cros_ec_cec, + int port_num, struct device *hdmi_dev, + const char * const *conns) +{ + struct cros_ec_cec_port *port; + int ret; + + port = devm_kzalloc(dev, sizeof(*port), GFP_KERNEL); + if (!port) + return -ENOMEM; + + port->cros_ec_cec = cros_ec_cec; + port->port_num = port_num; + + port->adap = cec_allocate_adapter(&cros_ec_cec_ops, port, DRV_NAME, + CEC_CAP_DEFAULTS | + CEC_CAP_CONNECTOR_INFO, 1); + if (IS_ERR(port->adap)) + return PTR_ERR(port->adap); + + if (!conns[port_num]) { + dev_err(dev, "no conn for port %d\n", port_num); + ret = -ENODEV; + goto out_probe_adapter; + } + + port->notify = cec_notifier_cec_adap_register(hdmi_dev, conns[port_num], + port->adap); + if (!port->notify) { + ret = -ENOMEM; + goto out_probe_adapter; + } + + ret = cec_register_adapter(port->adap, dev); + if (ret < 0) + goto out_probe_notify; + + cros_ec_cec->ports[port_num] = port; + + return 0; + +out_probe_notify: + cec_notifier_cec_adap_unregister(port->notify, port->adap); +out_probe_adapter: + cec_delete_adapter(port->adap); + return ret; +} + +static int cros_ec_cec_probe(struct platform_device *pdev) +{ + struct cros_ec_dev *ec_dev = dev_get_drvdata(pdev->dev.parent); + struct cros_ec_device *cros_ec = ec_dev->ec_dev; + struct cros_ec_cec *cros_ec_cec; + struct cros_ec_cec_port *port; + struct device *hdmi_dev; + const char * const *conns = NULL; + int ret; + + hdmi_dev = cros_ec_cec_find_hdmi_dev(&pdev->dev, &conns); + if (IS_ERR(hdmi_dev)) + return PTR_ERR(hdmi_dev); + + cros_ec_cec = devm_kzalloc(&pdev->dev, sizeof(*cros_ec_cec), + GFP_KERNEL); + if (!cros_ec_cec) + return -ENOMEM; + + platform_set_drvdata(pdev, cros_ec_cec); + cros_ec_cec->cros_ec = cros_ec; + + device_init_wakeup(&pdev->dev, 1); + + ret = cros_ec_cec_get_num_ports(cros_ec_cec); + if (ret) + return ret; + + ret = cros_ec_cec_get_write_cmd_version(cros_ec_cec); + if (ret) + return ret; + + for (int i = 0; i < cros_ec_cec->num_ports; i++) { + ret = cros_ec_cec_init_port(&pdev->dev, cros_ec_cec, i, + hdmi_dev, conns); + if (ret) + goto unregister_ports; + } + + /* Get CEC events from the EC. */ + cros_ec_cec->notifier.notifier_call = cros_ec_cec_event; + ret = blocking_notifier_chain_register(&cros_ec->event_notifier, + &cros_ec_cec->notifier); + if (ret) { + dev_err(&pdev->dev, "failed to register notifier\n"); + goto unregister_ports; + } + + return 0; + +unregister_ports: + /* + * Unregister any adapters which have been registered. We don't add the + * port to the array until the adapter has been registered successfully, + * so any non-NULL ports must have been registered. + */ + for (int i = 0; i < cros_ec_cec->num_ports; i++) { + port = cros_ec_cec->ports[i]; + if (!port) + break; + cec_notifier_cec_adap_unregister(port->notify, port->adap); + cec_unregister_adapter(port->adap); + } + return ret; +} + +static void cros_ec_cec_remove(struct platform_device *pdev) +{ + struct cros_ec_cec *cros_ec_cec = platform_get_drvdata(pdev); + struct device *dev = &pdev->dev; + struct cros_ec_cec_port *port; + int ret; + + /* + * blocking_notifier_chain_unregister() only fails if the notifier isn't + * in the list. We know it was added to it by .probe(), so there should + * be no need for error checking. Be cautious and still check. + */ + ret = blocking_notifier_chain_unregister( + &cros_ec_cec->cros_ec->event_notifier, + &cros_ec_cec->notifier); + if (ret) + dev_err(dev, "failed to unregister notifier\n"); + + for (int i = 0; i < cros_ec_cec->num_ports; i++) { + port = cros_ec_cec->ports[i]; + cec_notifier_cec_adap_unregister(port->notify, port->adap); + cec_unregister_adapter(port->adap); + } +} + +static const struct platform_device_id cros_ec_cec_id[] = { + { DRV_NAME, 0 }, + {} +}; +MODULE_DEVICE_TABLE(platform, cros_ec_cec_id); + +static struct platform_driver cros_ec_cec_driver = { + .probe = cros_ec_cec_probe, + .remove = cros_ec_cec_remove, + .driver = { + .name = DRV_NAME, + .pm = &cros_ec_cec_pm_ops, + }, + .id_table = cros_ec_cec_id, +}; + +module_platform_driver(cros_ec_cec_driver); + +MODULE_DESCRIPTION("CEC driver for ChromeOS ECs"); +MODULE_AUTHOR("Neil Armstrong "); +MODULE_LICENSE("GPL"); diff --git a/patches/gamescope-display-wrapper.sh b/patches/gamescope-display-wrapper.sh new file mode 100644 index 0000000..5a632be --- /dev/null +++ b/patches/gamescope-display-wrapper.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Steamify: gamescope-session starts gamescope without a size or a refresh rate, so it takes the display's *preferred* mode, +# which many monitors declare as 60 Hz (the faster modes sit in an extension block of the EDID). This starts it the way the +# desktop runs that display: the mode KDE has saved for the connected display (System Settings > Display), when the display +# still has it, else its biggest mode at the highest refresh rate. +# Read at every session start (modetest, part of libdrm which gamescope needs, and KDE's ~/.config/kwinoutputconfig.json), so +# changing the resolution in KDE, another monitor or a TV just works. Anything unclear (no modetest output, no modes, more than +# one display connected: gamescope chooses which one it uses) leaves gamescope's own choice alone. +real=/usr/bin/gamescope + +modes() { + # One line per mode of every connected connector: " " + local m + for m in nvidia-drm amdgpu i915 xe nouveau radeon; do + timeout 10 modetest -M "$m" -c 2>/dev/null | awk ' + /^[0-9]+[ \t]+[0-9]+[ \t]+(connected|disconnected|unknown)/ { conn = ($3 == "connected") ? $4 : ""; next } + conn != "" && /^[ \t]+#[0-9]+ [0-9]+x[0-9]+ [0-9.]+ / && !/interlace/ { + split($2, wh, "x"); printf "%s %d %d %.0f\n", conn, wh[1], wh[2], $3 + }' + done +} + +kde_mode() { + # kde_mode : " " as KDE has it saved for that connector, or nothing. + python3 -I - "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/kwinoutputconfig.json" 2>/dev/null << 'EOF' +import json, sys +try: + for block in json.load(open(sys.argv[2])): + if block.get("name") == "outputs": + for o in block.get("data", []): + m = o.get("mode") + if o.get("connectorName") == sys.argv[1] and m: + print(m["width"], m["height"], round(m["refreshRate"] / 1000)) +except Exception: + pass +EOF +} + +args=() +all="$(modes)" +conn="$(awk '{print $1}' <<< "$all" | sort -u)" +if [[ -n "$conn" && "$(wc -l <<< "$conn")" == 1 ]]; then + read -r w h r <<< "$(kde_mode "$conn")" + # KDE's mode only counts when the display really has it (a refresh rate within 1 Hz). + if [[ -z "${w:-}" ]] || ! awk -v w="$w" -v h="${h:-0}" -v r="${r:-0}" '$2 == w && $3 == h && ($4 - r < 2 && r - $4 < 2) {f = 1} END {exit !f}' <<< "$all"; then + read -r _ w h r < <(awk '{print $2 * $3, $2, $3, $4}' <<< "$all" | sort -k1,1nr -k4,4nr | head -1 | awk '{print "x", $2, $3, $4}') + fi + [[ "${w:-0}" -gt 0 && "${h:-0}" -gt 0 && "${r:-0}" -gt 0 ]] && args=(-W "$w" -H "$h" -r "$r") +fi +if [[ "${1:-}" == --print-args ]]; then echo "${args[*]}"; exit 0; fi +exec "$real" "${args[@]}" "$@" diff --git a/patches/gamescope-gbm-scanout.patch b/patches/gamescope-gbm-scanout.patch new file mode 100644 index 0000000..ed4bc90 --- /dev/null +++ b/patches/gamescope-gbm-scanout.patch @@ -0,0 +1,1025 @@ +GBM-allocated scan-out buffers for gamescope's DRM backend on NVIDIA (opt-in: gamescope_drm_gbm_scanout=1). +Nine commits from https://github.com/farnoy/gamescope (branch nvidia-gbm-wayland: 5cf847274, ffb525cf7, 211f4ca26, +117b593ea, 8da9459ff, cb0378edc, 4e879fbf7, 0c7fddbf3, ae7b99df3), as one diff against gamescope 3.16.31. +Upstream issue: https://github.com/ValveSoftware/gamescope/issues/2309. +Tested on an RTX 5080 (driver 615.78.08): Steam's gaming UI is clean with it and shows artifacts without it. +Applies with `git apply` (it ignores these lines); newer gamescope versions need a rebase. + +diff --git a/README.md b/README.md +index 0f53c60..7de4241 100644 +--- a/README.md ++++ b/README.md +@@ -19,7 +19,7 @@ If running RadeonSI clients with older cards (GFX8 and below), currently have to + + Dependent first (**Debian/Debian-based**): + ``` +-apt install meson ninja-build pkg-config cmake libpipewire-0.3-dev hwdata libx11-dev libwayland-dev libvulkan-dev wayland-protocols libx11-xcb-dev libxdamage-dev libxcomposite-dev libxcursor-dev libxxf86vm-dev libxtst-dev libxres-dev libxmu-dev libxkbcommon-dev libcap-dev libsdl2-dev libavif-dev libpixman-1-dev liblcms2-dev libseat-dev libinput-dev xwayland libxcb-composite0-dev libxcb-ewmh-dev libxcb-icccm4-dev libxcb-res0-dev glslang-tools libluajit-5.1-dev libcatch2-dev ++apt install meson ninja-build pkg-config cmake libpipewire-0.3-dev hwdata libx11-dev libwayland-dev libvulkan-dev libgbm-dev wayland-protocols libx11-xcb-dev libxdamage-dev libxcomposite-dev libxcursor-dev libxxf86vm-dev libxtst-dev libxres-dev libxmu-dev libxkbcommon-dev libcap-dev libsdl2-dev libavif-dev libpixman-1-dev liblcms2-dev libseat-dev libinput-dev xwayland libxcb-composite0-dev libxcb-ewmh-dev libxcb-icccm4-dev libxcb-res0-dev glslang-tools libluajit-5.1-dev libcatch2-dev + ``` + + Build with: +diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp +index 9aaab7b..9b40f02 100644 +--- a/src/Backends/DRMBackend.cpp ++++ b/src/Backends/DRMBackend.cpp +@@ -56,6 +56,10 @@ + #include "libdisplay-info/cta.h" + #include "wlr_end.hpp" + ++#if HAVE_GBM ++#include "GbmScanoutBuffer.h" ++#endif ++ + #include "gamescope-control-protocol.h" + + extern int g_nPreferredOutputWidth; +@@ -75,6 +79,19 @@ gamescope::ConVar cv_drm_debug_disable_color_range( "drm_debug_disable_col + gamescope::ConVar cv_drm_debug_disable_explicit_sync( "drm_debug_disable_explicit_sync", false, "Force disable explicit sync on the DRM backend." ); + gamescope::ConVar cv_drm_debug_disable_in_fence_fd( "drm_debug_disable_in_fence_fd", false, "Force disable IN_FENCE_FD being set to avoid over-synchronization on the DRM backend." ); + ++gamescope::ConVar cv_drm_gbm_scanout( "drm_gbm_scanout", false, "Allocate scanout buffers with GBM and import them into Vulkan. Fixes corrupted scanout on the NVIDIA proprietary driver.", ++ []( gamescope::ConVar &cvar ) ++ { ++ // The callback also runs for writes of the unchanged value; only an ++ // actual toggle warrants rebuilding the output images. ++ static bool s_bLastValue = false; ++ if ( cvar == s_bLastValue ) ++ return; ++ s_bLastValue = cvar; ++ ++ g_bForceOutputImageRemake = true; ++ }); ++ + gamescope::ConVar cv_drm_allow_dynamic_modes_for_external_display( "drm_allow_dynamic_modes_for_external_display", false, "Allow dynamic mode/refresh rate switching for external displays." ); + + int HackyDRMPresent( const FrameInfo_t *pFrameInfo, bool bAsync ); +@@ -113,6 +130,13 @@ struct drm_t { + bool allow_modifiers; + struct wlr_drm_format_set formats; + ++ // GBM device on a dup of the KMS fd, for backend-allocated scanout ++ // buffers. Buffers keep the device alive via this shared_ptr; the ++ // deleter destroys the device and closes its dup'd fd, so buffer ++ // teardown stays valid even after drm->fd itself is closed. ++ std::shared_ptr gbm; ++ bool bIsNvidia = false; ++ + std::vector< std::unique_ptr< gamescope::CDRMPlane > > planes; + std::vector< std::unique_ptr< gamescope::CDRMCRTC > > crtcs; + std::unordered_map< uint32_t, gamescope::CDRMConnector > connectors; +@@ -547,10 +571,11 @@ namespace gamescope + ~CDRMFb(); + + uint32_t GetFbId() const { return m_uFbId; } +- ++ + private: + uint32_t m_uFbId = 0; + }; ++ + } + + uint32_t g_nDRMFormat = DRM_FORMAT_INVALID; +@@ -1546,6 +1571,28 @@ bool init_drm(struct drm_t *drm, int width, int height, int refresh) + drm->allow_modifiers = true; + } + ++ if ( drmVersion *pVersion = drmGetVersion( drm->fd ) ) ++ { ++ drm->bIsNvidia = pVersion->name && !strcmp( pVersion->name, "nvidia-drm" ); ++ drm_log.infof( "KMS driver: %s", pVersion->name ? pVersion->name : "unknown" ); ++ drmFreeVersion( pVersion ); ++ } ++ ++#if HAVE_GBM ++ // Create the GBM device on a dup of the KMS fd so buffer teardown ++ // (gbm_bo_destroy) stays valid even after wlsession_close_kms() has ++ // closed drm->fd; textures holding GBM buffers can outlive the backend ++ // on shutdown. The deleter closes the dup'd fd after the device. ++ drm->gbm = gamescope::CreateGbmDevice( drm->fd ); ++ if ( !drm->gbm ) ++ drm_log.errorf( "GBM scanout buffers will be unavailable." ); ++ ++ if ( drm->bIsNvidia && !cv_drm_gbm_scanout ) ++ { ++ drm_log.infof( "nvidia-drm detected: scanout of Vulkan-allocated buffers is known to corrupt on this driver. Consider enabling GBM scanout buffers with gamescope_drm_gbm_scanout=1." ); ++ } ++#endif ++ + g_bSupportsAsyncFlips = drmGetCap(drm->fd, DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP, &cap) == 0 && cap != 0; + if (!g_bSupportsAsyncFlips) + drm_log.errorf("Immediate flips are not supported by the KMS driver"); +@@ -1842,6 +1889,12 @@ void finish_drm(struct drm_t *drm) + drm->crtcs.clear(); + drm->connectors.clear(); + ++ // Drop our reference to the GBM device. Outstanding scanout buffers ++ // keep it alive via their own references; the device owns a dup of the ++ // KMS fd, so late gbm_bo_destroy calls stay valid after ++ // wlsession_close_kms() below. ++ drm->gbm = nullptr; ++ + + // Signal the page-flip handler thread to exit and join it so it won't be + // using the DRM fd while we clean it up. Closing the pipe write end +@@ -4227,6 +4280,27 @@ namespace gamescope + return drm_fbid_from_dmabuf( &g_DRM, pDmaBuf ); + } + ++ virtual bool SupportsExternalScanoutBuffers() const override ++ { ++#if HAVE_GBM ++ return g_DRM.gbm != nullptr && g_DRM.allow_modifiers && cv_drm_gbm_scanout; ++#else ++ return false; ++#endif ++ } ++ ++ virtual std::shared_ptr CreateScanoutBuffer( uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span ulModifiers, bool bLinear ) override ++ { ++#if HAVE_GBM ++ if ( !g_DRM.gbm ) ++ return nullptr; ++ ++ return CreateGbmScanoutBuffer( g_DRM.gbm, uWidth, uHeight, uDrmFormat, ulModifiers, bLinear ); ++#else ++ return nullptr; ++#endif ++ } ++ + virtual bool UsesModifiers() const override + { + return g_DRM.allow_modifiers; +diff --git a/src/Backends/DeferredBackend.h b/src/Backends/DeferredBackend.h +index 7f76122..a7385b3 100644 +--- a/src/Backends/DeferredBackend.h ++++ b/src/Backends/DeferredBackend.h +@@ -163,6 +163,25 @@ namespace gamescope + return m_pChild->ImportDmabufToBackend( pDmaBuf ); + } + ++ virtual bool SupportsExternalScanoutBuffers() const override ++ { ++ { ++ std::shared_lock lock{ m_mutInit }; ++ if ( m_bInittedChild ) ++ return m_pChild->SupportsExternalScanoutBuffers(); ++ } ++ return false; ++ } ++ virtual std::shared_ptr CreateScanoutBuffer( uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span ulModifiers, bool bLinear ) override ++ { ++ { ++ std::shared_lock lock{ m_mutInit }; ++ if ( m_bInittedChild ) ++ return m_pChild->CreateScanoutBuffer( uWidth, uHeight, uDrmFormat, ulModifiers, bLinear ); ++ } ++ return nullptr; ++ } ++ + virtual bool UsesModifiers() const override + { + return true; +diff --git a/src/Backends/GbmScanoutBuffer.cpp b/src/Backends/GbmScanoutBuffer.cpp +new file mode 100644 +index 0000000..401f679 +--- /dev/null ++++ b/src/Backends/GbmScanoutBuffer.cpp +@@ -0,0 +1,158 @@ ++#include "GbmScanoutBuffer.h" ++ ++#include "drm_include.h" ++#include "log.hpp" ++ ++#include ++#include ++#include ++ ++#include ++ ++namespace gamescope ++{ ++ static LogScope s_GbmLog( "gbm" ); ++ ++ class CGbmScanoutBuffer final : public IBackendScanoutBuffer ++ { ++ public: ++ CGbmScanoutBuffer( std::shared_ptr pDevice, gbm_bo *pBo, const wlr_dmabuf_attributes &attrs ) ++ : m_pDevice{ std::move( pDevice ) } ++ , m_pBo{ pBo } ++ , m_Attrs{ attrs } ++ { ++ } ++ ++ ~CGbmScanoutBuffer() ++ { ++ wlr_dmabuf_attributes_finish( &m_Attrs ); ++ gbm_bo_destroy( m_pBo ); ++ } ++ ++ const wlr_dmabuf_attributes *GetDmabufAttributes() const override { return &m_Attrs; } ++ ++ private: ++ // Declared before m_pBo so the device outlives the bo. ++ std::shared_ptr m_pDevice; ++ gbm_bo *m_pBo = nullptr; ++ wlr_dmabuf_attributes m_Attrs = {}; ++ }; ++ ++ std::shared_ptr CreateGbmDevice( int nFd ) ++ { ++ int nGbmFd = dup( nFd ); ++ if ( nGbmFd < 0 ) ++ { ++ s_GbmLog.errorf_errno( "Failed to dup fd for GBM" ); ++ return nullptr; ++ } ++ ++ gbm_device *pDevice = gbm_create_device( nGbmFd ); ++ if ( !pDevice ) ++ { ++ close( nGbmFd ); ++ s_GbmLog.errorf( "Failed to create GBM device" ); ++ return nullptr; ++ } ++ ++ s_GbmLog.infof( "Created GBM device (backend: %s)", gbm_device_get_backend_name( pDevice ) ); ++ return std::shared_ptr( pDevice, [ nGbmFd ]( gbm_device *pDevice ){ gbm_device_destroy( pDevice ); close( nGbmFd ); } ); ++ } ++ ++ std::shared_ptr CreateGbmScanoutBuffer( const std::shared_ptr &pDevice, uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span ulModifiers, bool bLinear ) ++ { ++ if ( !pDevice ) ++ return nullptr; ++ ++ uint32_t uFlags = GBM_BO_USE_RENDERING | GBM_BO_USE_SCANOUT; ++ ++ bool bForceLinearModifier = false; ++ struct gbm_bo *pBo = nullptr; ++ if ( bLinear ) ++ { ++ // GBM rejects GBM_BO_USE_LINEAR combined with an explicit ++ // modifier list (EINVAL in Mesa), so allocate directly. ++ pBo = gbm_bo_create( pDevice.get(), uWidth, uHeight, uDrmFormat, uFlags | GBM_BO_USE_LINEAR ); ++ bForceLinearModifier = true; ++ } ++ else if ( !ulModifiers.empty() ) ++ { ++ pBo = gbm_bo_create_with_modifiers2( pDevice.get(), uWidth, uHeight, uDrmFormat, ulModifiers.data(), ulModifiers.size(), uFlags ); ++ ++ // Some GBM implementations cannot allocate with an explicit ++ // modifier list at all. If only LINEAR was requested, a plain ++ // allocation with GBM_BO_USE_LINEAR is equivalent. ++ if ( !pBo && ulModifiers.size() == 1 && ulModifiers[0] == DRM_FORMAT_MOD_LINEAR ) ++ { ++ pBo = gbm_bo_create( pDevice.get(), uWidth, uHeight, uDrmFormat, uFlags | GBM_BO_USE_LINEAR ); ++ bForceLinearModifier = true; ++ } ++ } ++ ++ if ( !pBo ) ++ { ++ s_GbmLog.errorf( "GBM allocation failed for %ux%u format 0x%x", uWidth, uHeight, uDrmFormat ); ++ return nullptr; ++ } ++ ++ wlr_dmabuf_attributes attrs = ++ { ++ .width = int32_t( uWidth ), ++ .height = int32_t( uHeight ), ++ .format = uDrmFormat, ++ .modifier = bForceLinearModifier ? DRM_FORMAT_MOD_LINEAR : gbm_bo_get_modifier( pBo ), ++ }; ++ ++ const int nPlaneCount = gbm_bo_get_plane_count( pBo ); ++ bool bSuccess = nPlaneCount >= 1 && nPlaneCount <= WLR_DMABUF_MAX_PLANES; ++ ++ if ( bSuccess && !bForceLinearModifier && !Algorithm::Contains( ulModifiers, attrs.modifier ) ) ++ { ++ s_GbmLog.errorf( "GBM returned modifier 0x%" PRIx64 " which was not in the allowed list", attrs.modifier ); ++ bSuccess = false; ++ } ++ ++ // Only count a plane in n_planes once we own its fd, so that ++ // wlr_dmabuf_attributes_finish never closes an fd we didn't acquire. ++ for ( int i = 0; bSuccess && i < nPlaneCount; i++ ) ++ { ++ int nFd = gbm_bo_get_fd_for_plane( pBo, i ); ++ if ( nFd < 0 ) ++ { ++ s_GbmLog.errorf_errno( "gbm_bo_get_fd_for_plane failed" ); ++ bSuccess = false; ++ break; ++ } ++ attrs.fd[i] = nFd; ++ attrs.offset[i] = gbm_bo_get_offset( pBo, i ); ++ attrs.stride[i] = gbm_bo_get_stride_for_plane( pBo, i ); ++ attrs.n_planes = i + 1; ++ } ++ bSuccess = bSuccess && attrs.n_planes == nPlaneCount; ++ ++ // Vulkan's dmabuf memory import is single-fd: all planes must ++ // reference the same underlying buffer. ++ for ( int i = 1; bSuccess && i < attrs.n_planes; i++ ) ++ { ++ struct stat first, other; ++ if ( fstat( attrs.fd[0], &first ) != 0 || fstat( attrs.fd[i], &other ) != 0 || ++ first.st_dev != other.st_dev || first.st_ino != other.st_ino ) ++ { ++ s_GbmLog.errorf( "GBM returned a disjoint multi-plane bo, cannot import" ); ++ bSuccess = false; ++ } ++ } ++ ++ if ( !bSuccess ) ++ { ++ wlr_dmabuf_attributes_finish( &attrs ); ++ gbm_bo_destroy( pBo ); ++ return nullptr; ++ } ++ ++ s_GbmLog.debugf( "allocated %ux%u format 0x%x modifier 0x%" PRIx64 " (%d planes)", ++ uWidth, uHeight, uDrmFormat, attrs.modifier, attrs.n_planes ); ++ ++ return std::make_shared( pDevice, pBo, attrs ); ++ } ++} +diff --git a/src/Backends/GbmScanoutBuffer.h b/src/Backends/GbmScanoutBuffer.h +new file mode 100644 +index 0000000..9dbf3b2 +--- /dev/null ++++ b/src/Backends/GbmScanoutBuffer.h +@@ -0,0 +1,20 @@ ++#pragma once ++ ++#include "backend.h" ++ ++#include ++#include ++#include ++ ++struct gbm_device; ++ ++namespace gamescope ++{ ++ // Creates a GBM device on a dup of nFd. The device owns the dup, so buffers ++ // that hold the device keep working after the caller closes nFd. ++ std::shared_ptr CreateGbmDevice( int nFd ); ++ ++ // Allocates a scanout-capable buffer with GBM_BO_USE_SCANOUT. ulModifiers ++ // must not contain DRM_FORMAT_MOD_INVALID; returns nullptr on failure. ++ std::shared_ptr CreateGbmScanoutBuffer( const std::shared_ptr &pDevice, uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span ulModifiers, bool bLinear ); ++} +diff --git a/src/backend.h b/src/backend.h +index bd57c42..12e4361 100644 +--- a/src/backend.h ++++ b/src/backend.h +@@ -290,6 +290,17 @@ namespace gamescope + virtual ~IBackendPlane() = default; + }; + ++ // A scanout-capable buffer allocated by the backend rather than by Vulkan ++ // (GBM on the DRM backend). Owns the underlying allocation and the fds ++ // inside its wlr_dmabuf_attributes; callers must dup() any fds they keep. ++ class IBackendScanoutBuffer ++ { ++ public: ++ virtual ~IBackendScanoutBuffer() {} ++ ++ virtual const wlr_dmabuf_attributes *GetDmabufAttributes() const = 0; ++ }; ++ + class CBaseBackendFb : public IBackendFb + { + public: +@@ -341,6 +352,19 @@ namespace gamescope + // Rc manages acquire/release of buffer to/from client while imported. + virtual OwningRc ImportDmabufToBackend( wlr_dmabuf_attributes *pDmaBuf ) = 0; + ++ // Whether the backend can allocate scanout-capable buffers itself ++ // (GBM on the DRM backend) for the renderer to import, instead of the ++ // renderer allocating through Vulkan and exporting. ++ virtual bool SupportsExternalScanoutBuffers() const { return false; } ++ // ulModifiers: final allowed modifier list -- already intersected by the ++ // caller across KMS support ( GetSupportedModifiers ) and Vulkan ++ // importability for every format that will alias this buffer. ++ // Never contains DRM_FORMAT_MOD_INVALID. ++ virtual std::shared_ptr CreateScanoutBuffer( uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span ulModifiers, bool bLinear ) ++ { ++ return nullptr; ++ } ++ + virtual bool UsesModifiers() const = 0; + virtual std::span GetSupportedModifiers( uint32_t uDrmFormat ) const = 0; + inline bool SupportsFormat( uint32_t uDrmFormat ) const +diff --git a/src/meson.build b/src/meson.build +index ae3ac16..12fe4ae 100644 +--- a/src/meson.build ++++ b/src/meson.build +@@ -11,6 +11,8 @@ dep_xmu = dependency('xmu') + dep_xi = dependency('xi') + + drm_dep = dependency('libdrm', version: '>= 2.4.113', required: get_option('drm_backend')) ++# gbm_bo_create_with_modifiers2 needs Mesa >= 21.3 ++gbm_dep = dependency('gbm', version: '>= 21.3', required: false) + eis_dep = dependency('libeis-1.0', required : get_option('input_emulation')) + + wayland_server = dependency('wayland-server', version: '>=1.21') +@@ -149,6 +151,11 @@ if sdl2_dep.found() + endif + + gamescope_cpp_args += '-DHAVE_DRM=@0@'.format(drm_dep.found().to_int()) ++have_gbm = drm_dep.found() and gbm_dep.found() ++if have_gbm ++ src += 'Backends/GbmScanoutBuffer.cpp' ++endif ++gamescope_cpp_args += '-DHAVE_GBM=@0@'.format(have_gbm.to_int()) + gamescope_cpp_args += '-DHAVE_SDL2=@0@'.format(sdl2_dep.found().to_int()) + gamescope_cpp_args += '-DHAVE_AVIF=@0@'.format(avif_dep.found().to_int()) + gamescope_cpp_args += '-DHAVE_LIBCAP=@0@'.format(cap_dep.found().to_int()) +@@ -196,7 +203,7 @@ gamescope_version = configure_file( + include_directories : [reshade_include, sol2_include], + dependencies: [ + dep_wayland, dep_x11, dep_xdamage, dep_xcomposite, dep_xrender, dep_xext, dep_xfixes, +- dep_xxf86vm, dep_xres, glm_dep, drm_dep, wayland_server, ++ dep_xxf86vm, dep_xres, glm_dep, drm_dep, gbm_dep, wayland_server, + xkbcommon, thread_dep, sdl2_dep, wlroots_dep, + vulkan_dep, liftoff_dep, dep_xtst, dep_xmu, cap_dep, epoll_dep, pipewire_dep, librt_dep, + stb_dep, displayinfo_dep, openvr_dep, dep_xcursor, avif_dep, dep_xi, +diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp +index 54e4efc..cd5769c 100644 +--- a/src/rendervulkan.cpp ++++ b/src/rendervulkan.cpp +@@ -2080,44 +2080,109 @@ static VkImageViewType VulkanImageTypeToViewType(VkImageType type) + } + } + +-bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA /* = nullptr */, uint32_t contentWidth /* = 0 */, uint32_t contentHeight /* = 0 */, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc pBackendFb ) ++static VkImageUsageFlags VulkanUsageFromCreateFlags( const CVulkanTexture::createFlags &flags ) + { +- m_pBackendFb = std::move( pBackendFb ); +- m_drmFormat = drmFormat; +- VkResult res = VK_ERROR_INITIALIZATION_FAILED; +- +- VkImageTiling tiling = (flags.bMappable || flags.bLinear) ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL; + VkImageUsageFlags usage = 0; +- VkMemoryPropertyFlags properties; + + if ( flags.bSampled == true ) +- { + usage |= VK_IMAGE_USAGE_SAMPLED_BIT; +- } + + if ( flags.bStorage == true ) +- { + usage |= VK_IMAGE_USAGE_STORAGE_BIT; +- } + + if ( flags.bColorAttachment == true ) +- { + usage |= VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; +- } + +- if ( flags.bFlippable == true ) ++ if ( flags.bTransferSrc == true ) ++ usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; ++ ++ if ( flags.bTransferDst == true ) ++ usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT; ++ ++ return usage; ++} ++ ++static bool BSupportsExternalScanoutModifier( uint32_t drmFormat, VkImageType imageType, VkImageUsageFlags usage, uint64_t ulModifier ); ++static std::vector GetExternalScanoutModifiers( std::span drmFormats, const CVulkanTexture::createFlags &flags ); ++ ++bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA /* = nullptr */, uint32_t contentWidth /* = 0 */, uint32_t contentHeight /* = 0 */, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc pBackendFb, std::shared_ptr pScanoutBuffer ) ++{ ++ // A retry only makes sense when the external scanout buffer was chosen ++ // internally, not handed in by the caller (who then owns the fallback). ++ const bool bCanRetryWithoutExternal = pDMA == nullptr && pScanoutBuffer == nullptr; ++ ++ if ( BInitInternal( width, height, depth, drmFormat, flags, pDMA, contentWidth, contentHeight, pExistingImageToReuseMemory, std::move( pBackendFb ), std::move( pScanoutBuffer ), true ) ) ++ return true; ++ ++ if ( bCanRetryWithoutExternal && m_pScanoutBuffer != nullptr ) + { +- flags.bExportable = true; ++ vk_log.errorf( "Failed to init texture from external scanout buffer, retrying with Vulkan allocation" ); ++ ReleaseResources(); ++ return BInitInternal( width, height, depth, drmFormat, flags, nullptr, contentWidth, contentHeight, pExistingImageToReuseMemory, nullptr, nullptr, false ); + } + +- if ( flags.bTransferSrc == true ) +- { +- usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; ++ return false; ++} ++ ++bool CVulkanTexture::BInitInternal( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA, uint32_t contentWidth, uint32_t contentHeight, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc pBackendFb, std::shared_ptr pScanoutBuffer, bool bAllowExternalScanout ) ++{ ++ m_pBackendFb = std::move( pBackendFb ); ++ m_pScanoutBuffer = std::move( pScanoutBuffer ); ++ m_drmFormat = drmFormat; ++ VkResult res = VK_ERROR_INITIALIZATION_FAILED; ++ ++ VkImageTiling tiling = (flags.bMappable || flags.bLinear) ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL; ++ VkImageUsageFlags usage = VulkanUsageFromCreateFlags( flags ); ++ VkMemoryPropertyFlags properties; ++ ++ // Flippable images are allocated by the backend where supported (GBM on ++ // the DRM backend): scanout memory needs placement only the backend's ++ // allocator can guarantee (physically contiguous vidmem on NVIDIA). ++ // Output images alias two formats in one buffer and are handled by ++ // vulkan_make_output_images_external instead. ++ wlr_dmabuf_attributes externalScanoutAttrs = {}; ++ if ( bAllowExternalScanout && ++ flags.bFlippable && pDMA == nullptr && m_pScanoutBuffer == nullptr && ++ m_pBackendFb == nullptr && ++ !flags.bOutputImage && !flags.bMappable && pExistingImageToReuseMemory == nullptr && ++ flags.imageType == VK_IMAGE_TYPE_2D && depth == 1 && ++ GetBackend()->SupportsExternalScanoutBuffers() && g_device.supportsModifiers() ) ++ { ++ std::vector modifiers; ++ if ( flags.bLinear ) ++ { ++ // Only LINEAR is acceptable here, so probe just that instead of ++ // enumerating every KMS modifier. ++ if ( gamescope::Algorithm::Contains( GetBackend()->GetSupportedModifiers( drmFormat ), uint64_t( DRM_FORMAT_MOD_LINEAR ) ) && ++ BSupportsExternalScanoutModifier( drmFormat, flags.imageType, usage, DRM_FORMAT_MOD_LINEAR ) ) ++ modifiers = { DRM_FORMAT_MOD_LINEAR }; ++ } ++ else ++ { ++ const uint32_t uFormats[1] = { drmFormat }; ++ modifiers = GetExternalScanoutModifiers( uFormats, flags ); ++ } ++ ++ if ( !modifiers.empty() ) ++ m_pScanoutBuffer = GetBackend()->CreateScanoutBuffer( width, height, drmFormat, modifiers, flags.bLinear ); ++ ++ if ( m_pScanoutBuffer ) ++ { ++ externalScanoutAttrs = *m_pScanoutBuffer->GetDmabufAttributes(); ++ pDMA = &externalScanoutAttrs; ++ } ++ else ++ { ++ vk_log.debugf( "External scanout buffer unavailable for format 0x%x, using Vulkan allocation", drmFormat ); ++ } + } + +- if ( flags.bTransferDst == true ) ++ if ( flags.bFlippable == true && pDMA == nullptr ) + { +- usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT; ++ // Flippable images we allocate ourselves get exported to the backend. ++ // A flippable image created from an existing dmabuf (eg. a ++ // backend-allocated scanout buffer) is imported instead. ++ flags.bExportable = true; + } + + if ( flags.bMappable == true ) +@@ -2320,7 +2385,8 @@ bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uin + vk_errorf( res, "vkCreateImage failed" ); + return false; + } +- ++ m_bOwnsImage = true; ++ + VkMemoryRequirements memRequirements; + g_device.vk.GetImageMemoryRequirements(g_device.device(), m_vkImage, &memRequirements); + +@@ -2383,6 +2449,37 @@ bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uin + return false; + } + ++ // A dmabuf import must pick a memory type from the mask ++ // vkGetMemoryFdPropertiesKHR reports for the FD, intersected ++ // with the image's memory requirements - not just any type ++ // satisfying the requirements. ++ VkMemoryFdPropertiesKHR fdProps = { ++ .sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR, ++ }; ++ res = g_device.vk.GetMemoryFdPropertiesKHR( g_device.device(), VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, fd, &fdProps ); ++ if ( res != VK_SUCCESS ) ++ { ++ vk_errorf( res, "vkGetMemoryFdPropertiesKHR failed" ); ++ close( fd ); ++ return false; ++ } ++ ++ const uint32_t uImportTypeBits = memRequirements.memoryTypeBits & fdProps.memoryTypeBits; ++ int32_t nImportMemoryTypeIndex = g_device.findMemoryType( properties, uImportTypeBits ); ++ if ( nImportMemoryTypeIndex < 0 ) ++ { ++ // Fall back to any permitted type; a valid import beats the ++ // preferred property flags. ++ nImportMemoryTypeIndex = g_device.findMemoryType( 0, uImportTypeBits ); ++ } ++ if ( nImportMemoryTypeIndex < 0 ) ++ { ++ vk_log.errorf( "No compatible memory type for dmabuf import" ); ++ close( fd ); ++ return false; ++ } ++ allocInfo.memoryTypeIndex = uint32_t( nImportMemoryTypeIndex ); ++ + // Memory already provided by pDMA + importMemoryInfo = { + .sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR, +@@ -2547,13 +2644,43 @@ bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uin + + m_dmabuf = dmabuf; + } ++ else if ( pDMA != nullptr && flags.bFlippable == true ) ++ { ++ // Flippable image backed by an external (backend-allocated) dmabuf: ++ // keep our own copy of the attributes for pipewire/screenshots and ++ // for fbid creation. We own these fds; they are closed by ++ // wlr_dmabuf_attributes_finish in ~CVulkanTexture. ++ m_dmabuf = *pDMA; ++ m_dmabuf.n_planes = 0; ++ for ( int i = 0; i < pDMA->n_planes; i++ ) ++ { ++ m_dmabuf.fd[i] = dup( pDMA->fd[i] ); ++ if ( m_dmabuf.fd[i] < 0 ) ++ { ++ vk_log.errorf_errno( "dup failed" ); ++ return false; ++ } ++ m_dmabuf.n_planes = i + 1; ++ } ++ } + +- if ( flags.bFlippable == true ) ++ if ( flags.bFlippable == true && m_pBackendFb == nullptr ) + { + m_pBackendFb = GetBackend()->ImportDmabufToBackend( &m_dmabuf ); ++ if ( pDMA != nullptr && m_pBackendFb == nullptr ) ++ { ++ // For external scanout buffers the whole point is scanning out; ++ // fail so the caller can fall back to Vulkan allocation. ++ vk_log.errorf( "Failed to import external scanout buffer to backend" ); ++ return false; ++ } + } + +- bool bHasAlpha = pDMA ? DRMFormatHasAlpha( pDMA->format ) : true; ++ // Self-allocated scanout buffers (m_pScanoutBuffer) must behave exactly like ++ // the export path above: X-channel formats map to alpha-bearing VkFormats ++ // with identity swizzle, and storage usage stays allowed. Only genuinely ++ // client-imported buffers take the SWIZZLE_ONE/no-storage handling. ++ bool bHasAlpha = ( pDMA && !m_pScanoutBuffer ) ? DRMFormatHasAlpha( pDMA->format ) : true; + + if (!bHasAlpha ) + { +@@ -2742,9 +2869,10 @@ CVulkanTexture::CVulkanTexture( void ) + { + } + +-CVulkanTexture::~CVulkanTexture( void ) ++void CVulkanTexture::ReleaseResources() + { + wlr_dmabuf_attributes_finish( &m_dmabuf ); ++ m_dmabuf = {}; + + if ( m_pMappedData != nullptr && m_vkImageMemory ) + { +@@ -2764,24 +2892,47 @@ CVulkanTexture::~CVulkanTexture( void ) + m_linearView = VK_NULL_HANDLE; + } + ++ if ( m_lumaView != VK_NULL_HANDLE ) ++ { ++ g_device.vk.DestroyImageView( g_device.device(), m_lumaView, nullptr ); ++ m_lumaView = VK_NULL_HANDLE; ++ } ++ ++ if ( m_chromaView != VK_NULL_HANDLE ) ++ { ++ g_device.vk.DestroyImageView( g_device.device(), m_chromaView, nullptr ); ++ m_chromaView = VK_NULL_HANDLE; ++ } ++ + if ( m_pBackendFb != nullptr ) + m_pBackendFb = nullptr; + +- if ( m_vkImageMemory != VK_NULL_HANDLE ) ++ // Images we created are destroyed even when the memory isn't ours ++ // (aliased textures, or an init that failed before allocating memory). ++ // Swapchain-owned images are never destroyed here. ++ if ( m_vkImage != VK_NULL_HANDLE && m_bOwnsImage ) + { +- if ( m_vkImage != VK_NULL_HANDLE ) +- { +- g_device.vk.DestroyImage( g_device.device(), m_vkImage, nullptr ); +- m_vkImage = VK_NULL_HANDLE; +- } ++ g_device.vk.DestroyImage( g_device.device(), m_vkImage, nullptr ); ++ } ++ m_vkImage = VK_NULL_HANDLE; ++ m_bOwnsImage = false; + ++ if ( m_vkImageMemory != VK_NULL_HANDLE ) ++ { + g_device.vk.FreeMemory( g_device.device(), m_vkImageMemory, nullptr ); + m_vkImageMemory = VK_NULL_HANDLE; + } + ++ m_pScanoutBuffer = nullptr; ++ + m_bInitialized = false; + } + ++CVulkanTexture::~CVulkanTexture( void ) ++{ ++ ReleaseResources(); ++} ++ + int CVulkanTexture::memoryFence() + { + const VkMemoryGetFdInfoKHR memory_get_fd_info = { +@@ -3341,6 +3492,139 @@ bool vulkan_remake_swapchain( void ) + return bRet; + } + ++// Whether Vulkan can create + import a dmabuf image with this ++// format/modifier and usage. Uses the same srgb/linear mutable-format view ++// pair the real image in BInitInternal is created with. ++static bool BSupportsExternalScanoutModifier( uint32_t drmFormat, VkImageType imageType, VkImageUsageFlags usage, uint64_t ulModifier ) ++{ ++ VkImageCreateInfo imageInfo = { ++ .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, ++ .imageType = imageType, ++ .format = DRMFormatToVulkan( drmFormat, false ), ++ .mipLevels = 1, ++ .arrayLayers = 1, ++ .samples = VK_SAMPLE_COUNT_1_BIT, ++ .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT, ++ .usage = usage, ++ .sharingMode = VK_SHARING_MODE_EXCLUSIVE, ++ }; ++ ++ std::array formats = { ++ DRMFormatToVulkan( drmFormat, false ), ++ DRMFormatToVulkan( drmFormat, true ), ++ }; ++ ++ VkImageFormatListCreateInfo formatList = { ++ .sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO, ++ .viewFormatCount = (uint32_t)formats.size(), ++ .pViewFormats = formats.data(), ++ }; ++ ++ if ( formats[0] != formats[1] ) ++ { ++ formatList.pNext = std::exchange( imageInfo.pNext, &formatList ); ++ imageInfo.flags |= VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT; ++ } ++ ++ VkExternalImageFormatProperties externalImageProperties = { ++ .sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES, ++ }; ++ ++ VkResult res = getModifierProps( &imageInfo, ulModifier, &externalImageProperties ); ++ return res == VK_SUCCESS && ++ !!( externalImageProperties.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT ); ++} ++ ++// Modifiers that (a) the backend can scan out for every format in drmFormats, ++// and (b) Vulkan can create + import a dmabuf image with, using the usage ++// implied by the given createFlags, for every format in drmFormats. ++// This is the "plane ∩ renderer" intersection other compositors (eg. KWin) ++// use when allocating scanout buffers through GBM, extended across all ++// formats that will alias the same buffer. ++static std::vector GetExternalScanoutModifiers( std::span drmFormats, const CVulkanTexture::createFlags &flags ) ++{ ++ std::vector modifiers; ++ if ( drmFormats.empty() ) ++ return modifiers; ++ ++ const VkImageUsageFlags usage = VulkanUsageFromCreateFlags( flags ); ++ ++ for ( uint64_t modifier : GetBackend()->GetSupportedModifiers( drmFormats[0] ) ) ++ { ++ if ( modifier == DRM_FORMAT_MOD_INVALID ) ++ continue; ++ ++ bool bCompatible = true; ++ for ( uint32_t drmFormat : drmFormats ) ++ { ++ if ( !gamescope::Algorithm::Contains( GetBackend()->GetSupportedModifiers( drmFormat ), modifier ) || ++ !BSupportsExternalScanoutModifier( drmFormat, flags.imageType, usage, modifier ) ) ++ { ++ bCompatible = false; ++ break; ++ } ++ } ++ ++ if ( bCompatible ) ++ modifiers.push_back( modifier ); ++ } ++ ++ return modifiers; ++} ++ ++// Allocate the output images through the backend (GBM on DRM) and import ++// them into Vulkan, instead of allocating with Vulkan and exporting. ++// Fixes scanout on drivers that need special placement for scanout memory ++// (NVIDIA needs physical contiguity, which only its GBM path guarantees). ++static bool vulkan_make_output_images_external( VulkanOutput_t *pOutput, const CVulkanTexture::createFlags &outputImageflags, uint32_t uOutputWidth, uint32_t uOutputHeight ) ++{ ++ uint32_t uFormats[2] = { pOutput->uOutputFormat, DRM_FORMAT_INVALID }; ++ size_t nFormats = 1; ++ ++ const bool bOverlay = pOutput->uOutputFormatOverlay != VK_FORMAT_UNDEFINED && !kDisablePartialComposition; ++ if ( bOverlay ) ++ uFormats[nFormats++] = pOutput->uOutputFormatOverlay; ++ ++ std::vector modifiers = GetExternalScanoutModifiers( std::span( uFormats, nFormats ), outputImageflags ); ++ if ( modifiers.empty() ) ++ { ++ vk_log.errorf( "No modifier is scanout-capable and Vulkan-importable for all output formats" ); ++ return false; ++ } ++ ++ for ( size_t i = 0; i < 3; i++ ) ++ { ++ std::shared_ptr pScanoutBuffer = ++ GetBackend()->CreateScanoutBuffer( uOutputWidth, uOutputHeight, pOutput->uOutputFormat, modifiers, false ); ++ if ( !pScanoutBuffer ) ++ return false; ++ ++ // BInit never takes ownership of these attrs' fds; it dups what it keeps. ++ wlr_dmabuf_attributes attrs = *pScanoutBuffer->GetDmabufAttributes(); ++ ++ pOutput->outputImages[i] = new CVulkanTexture(); ++ if ( !pOutput->outputImages[i]->BInit( uOutputWidth, uOutputHeight, 1u, pOutput->uOutputFormat, outputImageflags, &attrs, 0, 0, nullptr, nullptr, pScanoutBuffer ) ) ++ return false; ++ ++ if ( bOverlay ) ++ { ++ // The overlay image is a second import of the same buffer with ++ // the overlay format: identical modifier and plane layout ++ // (both formats are 32bpp, so strides match), its own fbid, and ++ // its own kernel reference on the underlying memory. Both ++ // textures share the IBackendScanoutBuffer. ++ attrs = *pScanoutBuffer->GetDmabufAttributes(); ++ attrs.format = pOutput->uOutputFormatOverlay; ++ ++ pOutput->outputImagesPartialOverlay[i] = new CVulkanTexture(); ++ if ( !pOutput->outputImagesPartialOverlay[i]->BInit( uOutputWidth, uOutputHeight, 1u, pOutput->uOutputFormatOverlay, outputImageflags, &attrs, 0, 0, nullptr, nullptr, pScanoutBuffer ) ) ++ return false; ++ } ++ } ++ ++ return true; ++} ++ + static bool vulkan_make_output_images( VulkanOutput_t *pOutput ) + { + CVulkanTexture::createFlags outputImageflags; +@@ -3360,14 +3644,30 @@ static bool vulkan_make_output_images( VulkanOutput_t *pOutput ) + pOutput->outputImagesPartialOverlay[1] = nullptr; + pOutput->outputImagesPartialOverlay[2] = nullptr; + +- uint32_t uDRMFormat = pOutput->uOutputFormat; +- + // Output images are physical-oriented; the panel scans out unrotated. + uint32_t uOutputWidth = g_nOutputWidth; + uint32_t uOutputHeight = g_nOutputHeight; + if ( g_uOutputRotation & 1u ) + std::swap( uOutputWidth, uOutputHeight ); + ++ if ( GetBackend()->SupportsExternalScanoutBuffers() && g_device.supportsModifiers() ) ++ { ++ if ( vulkan_make_output_images_external( pOutput, outputImageflags, uOutputWidth, uOutputHeight ) ) ++ { ++ // Oh no. ++ pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture(); ++ return true; ++ } ++ ++ vk_log.errorf( "Failed to create backend (GBM) scanout buffers, falling back to Vulkan allocation" ); ++ for ( auto &pImage : pOutput->outputImages ) ++ pImage = nullptr; ++ for ( auto &pImage : pOutput->outputImagesPartialOverlay ) ++ pImage = nullptr; ++ } ++ ++ uint32_t uDRMFormat = pOutput->uOutputFormat; ++ + pOutput->outputImages[0] = new CVulkanTexture(); + bool bSuccess = pOutput->outputImages[0]->BInit( uOutputWidth, uOutputHeight, 1u, uDRMFormat, outputImageflags ); + if ( bSuccess != true ) +diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp +index fa519db..4f0bff0 100644 +--- a/src/rendervulkan.hpp ++++ b/src/rendervulkan.hpp +@@ -161,7 +161,7 @@ public: + VkImageType imageType; + }; + +- bool BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA = nullptr, uint32_t contentWidth = 0, uint32_t contentHeight = 0, CVulkanTexture *pExistingImageToReuseMemory = nullptr, gamescope::OwningRc pBackendFb = nullptr ); ++ bool BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA = nullptr, uint32_t contentWidth = 0, uint32_t contentHeight = 0, CVulkanTexture *pExistingImageToReuseMemory = nullptr, gamescope::OwningRc pBackendFb = nullptr, std::shared_ptr pScanoutBuffer = nullptr ); + bool BInitFromSwapchain( VkImage image, uint32_t width, uint32_t height, VkFormat format ); + + uint32_t IncRef(); +@@ -211,9 +211,17 @@ public: + uint32_t queueFamily = VK_QUEUE_FAMILY_IGNORED; + + private: ++ bool BInitInternal( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA, uint32_t contentWidth, uint32_t contentHeight, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc pBackendFb, std::shared_ptr pScanoutBuffer, bool bAllowExternalScanout ); ++ // Frees everything BInitInternal may have created, so a failed init can ++ // be retried. Also used by the destructor. ++ void ReleaseResources(); ++ + bool m_bInitialized = false; + bool m_bExternal = false; + bool m_bOutputImage = false; ++ // False for swapchain images: those are owned by the swapchain and must ++ // never be passed to vkDestroyImage. ++ bool m_bOwnsImage = false; + + uint32_t m_drmFormat = DRM_FORMAT_INVALID; + +@@ -251,6 +259,12 @@ private: + EStreamColorspace m_streamColorspace = k_EStreamColorspace_Unknown; + + struct wlr_dmabuf_attributes m_dmabuf = {}; ++ ++ // Set when the underlying memory was allocated by the backend (GBM on ++ // the DRM backend) and imported into Vulkan, rather than allocated by ++ // Vulkan. Keeps the backend allocation alive for the texture's lifetime; ++ // aliasing textures (eg. the partial-overlay output images) share it. ++ std::shared_ptr m_pScanoutBuffer; + }; + + struct vec2_t +@@ -788,6 +802,7 @@ static inline uint32_t div_roundup(uint32_t x, uint32_t y) + VK_FUNC(GetImageMemoryRequirements) \ + VK_FUNC(GetImageSubresourceLayout) \ + VK_FUNC(GetMemoryFdKHR) \ ++ VK_FUNC(GetMemoryFdPropertiesKHR) \ + VK_FUNC(GetQueryPoolResults) \ + VK_FUNC(GetSemaphoreCounterValue) \ + VK_FUNC(GetSwapchainImagesKHR) \ +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 2ff296b..2cb5eb5 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -161,6 +161,7 @@ static lut3d_t g_tmpLut3d; + extern int g_nDynamicRefreshHz; + + bool g_bForceHDRSupportDebug = false; ++std::atomic g_bForceOutputImageRemake = { false }; + extern float g_flInternalDisplayBrightnessNits; + extern float g_flHDRItmSdrNits; + extern float g_flHDRItmTargetNits; +@@ -10255,13 +10256,21 @@ steamcompmgr_main(int argc, char **argv) + // Pick our width/height for this potential frame, regardless of how it might change later + // At some point we might even add proper locking so we get real updates atomically instead + // of whatever jumble of races the below might cause over a couple of frames ++ // Consume the remake request exactly once, before evaluating the other ++ // conditions: a concurrent request (eg. the drm_gbm_scanout convar ++ // callback on the Wayland thread) must never be cleared unseen, only ++ // exchanged out - a write racing past this point stays set for the ++ // next iteration. ++ const bool bForceOutputImageRemake = g_bForceOutputImageRemake.exchange( false ); ++ + if ( currentOutputWidth != g_nOutputWidth || + currentOutputHeight != g_nOutputHeight || + currentOutputRefresh != g_nOutputRefresh || + currentOutputRotation != g_uOutputRotation || + currentHDROutput != g_bOutputHDREnabled || + currentHDRCapable != bOutputHDRCapable || +- currentHDRForce != g_bForceHDRSupportDebug ) ++ currentHDRForce != g_bForceHDRSupportDebug || ++ bForceOutputImageRemake ) + { + if ( g_nXWaylandCount > 1 ) + { +diff --git a/src/steamcompmgr.hpp b/src/steamcompmgr.hpp +index c1d1ab0..fc7a521 100644 +--- a/src/steamcompmgr.hpp ++++ b/src/steamcompmgr.hpp +@@ -22,6 +22,7 @@ void steamcompmgr_main(int argc, char **argv); + #include "vblankmanager.hpp" + #include "mangoapp_control.h" + ++#include + #include + #include + #include +@@ -45,6 +46,10 @@ static const uint32_t g_zposMuraCorrection = 5; + extern bool g_bHDRItmEnable; + extern bool g_bForceHDRSupportDebug; + ++// Request a rebuild of the output images on the next steamcompmgr iteration, ++// eg. after toggling scanout buffer allocation strategy at runtime. ++extern std::atomic g_bForceOutputImageRemake; ++ + extern EStreamColorspace g_ForcedNV12ColorSpace; + + struct CursorBarrierInfo diff --git a/patches/gamescope-nvidia.PKGBUILD b/patches/gamescope-nvidia.PKGBUILD new file mode 100644 index 0000000..2009b63 --- /dev/null +++ b/patches/gamescope-nvidia.PKGBUILD @@ -0,0 +1,128 @@ +# Maintainer: theupriser +# Based on Arch Linux's gamescope PKGBUILD (Maxime Gauduin, Giancarlo Razzolini, Sven-Hendrik Haase and contributors). +# +# gamescope with GBM-allocated scan-out buffers for NVIDIA (opt-in: gamescope_drm_gbm_scanout=1). +# The patches are gamescope-gbm-scanout.patch and gamescope-ui-native.patch (next to this file, applied in this order); see the +# README for where they come from. +# UPSTREAM_VERSION (next to this file) names the gamescope tag the patches are made for. + +pkgname=steamify-gamescope-nvidia +_srcname=gamescope +pkgver=3.16.31 +pkgrel=3 +pkgdesc='SteamOS session compositing window manager, with GBM scan-out for NVIDIA (opt-in: gamescope_drm_gbm_scanout=1)' +arch=(x86_64) +url=https://github.com/theupriser/steamify-gamescope-nvidia +license=( + BSD-2-Clause + BSD-3-Clause + LicenseRef-Reshade +) +provides=("gamescope=$pkgver") +conflicts=(gamescope gamescope-nvidia-gbm) +replaces=(gamescope-nvidia-gbm) +depends=( + glibc + mesa + libstdc++ + libgcc + lcms2 + libavif + libcap.so + libdecor + libdrm + libei + libinput + libpipewire-0.3.so + libx11 + libxcb + libxcomposite + libxcursor + libxdamage + libxext + libxfixes + libxi + libxkbcommon.so + libxmu + libxrender + libxres + libxtst + libxxf86vm + luajit + pixman + sdl2 + seatd + systemd-libs + vulkan-icd-loader + wayland + xcb-util-errors + xcb-util-wm + xorg-server-xwayland +) +makedepends=( + benchmark + cmake + git + glslang + meson + ninja + vulkan-headers + wayland-protocols + catch2 +) +source=( + gamescope::git+https://github.com/ValveSoftware/gamescope.git#commit=${pkgver} + gamescope-gbm-scanout.patch + gamescope-ui-native.patch + git+https://gitlab.freedesktop.org/emersion/libdisplay-info.git + git+https://gitlab.freedesktop.org/emersion/libliftoff.git + git+https://github.com/ValveSoftware/openvr.git + git+https://github.com/Joshua-Ashton/reshade.git + git+https://github.com/KhronosGroup/SPIRV-Headers.git + git+https://github.com/Joshua-Ashton/vkroots.git + git+https://gitlab.freedesktop.org/wlroots/wlroots.git +) +b2sums=('SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP') + +prepare() { + cd gamescope + + # GBM scan-out for NVIDIA. If it no longer applies to this gamescope version, stop here: the build must not + # silently produce an unpatched gamescope under this package's name. + git apply --check ../gamescope-gbm-scanout.patch || { echo "gbm patch does not apply to gamescope ${pkgver}" >&2; return 1; } + git apply ../gamescope-gbm-scanout.patch + git apply --check ../gamescope-ui-native.patch || { echo "ui-native patch does not apply to gamescope ${pkgver}" >&2; return 1; } + git apply ../gamescope-ui-native.patch + + meson subprojects download + + for submodule in src/reshade subprojects/{libdisplay-info,libliftoff,openvr,vkroots,wlroots} thirdparty/SPIRV-Headers; do + git submodule init ${submodule} + git config submodule.${submodule}.url ../${submodule##*/} + done + git -c protocol.file.allow=always submodule update +} + +build() { + arch-meson gamescope build \ + -Dforce_fallback_for=glm,stb,libdisplay-info,libliftoff,vkroots \ + -Dpipewire=enabled + meson compile -C build +} + +package() { + DESTDIR="${pkgdir}" meson install -C build \ + --skip-subprojects + install -Dm 644 gamescope/LICENSE -t "${pkgdir}"/usr/share/licenses/${pkgname}/ +} + +# vim: ts=2 sw=2 et: diff --git a/patches/gamescope-ui-native.patch b/patches/gamescope-ui-native.patch new file mode 100644 index 0000000..78473fc --- /dev/null +++ b/patches/gamescope-ui-native.patch @@ -0,0 +1,39 @@ +Keeps Steam's UI X server (server 0) at the output size when Steam asks for a smaller one. + +With -steamos3 (what gamescope-session-cachyos passes) Steam sets its UI to 1920x1080 right after it starts +(GAMESCOPE_XWAYLAND_MODE_CONTROL), so on a bigger display, and on one that isn't 16:9, the whole interface is a scaled-up +1080p picture. Games already run at the display's size. With this the server stays at the output size and Steam draws its +window to match; Steam's SteamOS features (settings pages, game launching) keep working. Written for Steamify on an NVIDIA +PC (RTX 5080, 3440x1440@175, tested with Steam's UI and a Proton game); not part of the farnoy patch. On by default; the +setting gamescope_xwayland_ui_native=0 (in gamescope's environment) turns it off. +Applies with `git apply` after gamescope-gbm-scanout.patch (it ignores these lines). + +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 2cb5eb5..ce14c8d 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -129,6 +129,8 @@ LogScope g_WaitableLog("waitable"); + + gamescope::ConVar cv_overlay_unmultiplied_alpha{ "overlay_unmultiplied_alpha", false }; + ++gamescope::ConVar cv_xwayland_ui_native( "xwayland_ui_native", true, "Keep the Steam UI X server (server 0) at the output size when Steam asks for a smaller one." ); ++ + gamescope::ConVar cv_vr_show_forwarded_overlays{ "vr_show_forwarded_overlays", false }; + + std::string *g_pVROverlayKey = nullptr; +@@ -7240,6 +7242,15 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev) + height = std::min(height, currentOutputHeight); + } + ++ // Steam asks for a smaller size for its own UI (server 0, 1920x1080 with -steamos3), which makes the whole ++ // interface a scaled-up picture on a bigger display, and on one that isn't 16:9 as well. The UI server ++ // stays at the output size: Steam sizes its window to the server. ++ if ( server_idx == 0 && cv_xwayland_ui_native ) ++ { ++ width = currentOutputWidth; ++ height = currentOutputHeight; ++ } ++ + gamescope_xwayland_server_t *server = wlserver_get_xwayland_server( server_idx ); + if ( server ) + { diff --git a/patches/python-hid-shim.PKGBUILD b/patches/python-hid-shim.PKGBUILD new file mode 100644 index 0000000..aac96f8 --- /dev/null +++ b/patches/python-hid-shim.PKGBUILD @@ -0,0 +1,17 @@ +# Maintainer: theupriser +# An empty package that only tells pacman "python-hid is there". For a PC that keeps python-hidapi (liquidctl needs it): +# the real python-hid conflicts with it, and jupiter-hw-support (a dependency of gamescope-session-cachyos) wants python-hid. +# Only Valve's Steam Deck controller firmware updater in jupiter-hw-support uses it, which a PC doesn't run. +# Steamify (lib/packages.sh) builds it with makepkg and installs it before the conversion's packages. +pkgname=steamify-python-hid-shim +pkgver=1 +pkgrel=1 +pkgdesc='Placeholder: python-hid for PCs that keep python-hidapi (they conflict)' +arch=(any) +license=(MIT) +depends=(python-hidapi) +provides=('python-hid=99') + +package() { + : +} diff --git a/patches/steamify-fremont-poweroff.c b/patches/steamify-fremont-poweroff.c index b7c7c8a..0a3f1bb 100644 --- a/patches/steamify-fremont-poweroff.c +++ b/patches/steamify-fremont-poweroff.c @@ -7,6 +7,11 @@ * leaves set on GPIO pin 18 (_SB.PCI0.GPP6) stays set, and the machine starts again right after powering off. * Valve's kernel clears that bit at probe on Fremont (not for upstream); * this does the same right before power-off, for kernels without it. + * + * What it does follows Valve's Steam Machine kernel + * (https://github.com/evlaV/linux-integration, GPL-2.0 like the rest of the + * Linux kernel); this module is Steamify's own implementation of it, also + * GPL-2.0. Not affiliated with or endorsed by Valve Corporation. */ #include #include diff --git a/steamify.sh b/steamify.sh index dcfa49a..424923d 100755 --- a/steamify.sh +++ b/steamify.sh @@ -15,11 +15,11 @@ set -uo pipefail # Release version, see CHANGELOG.md. -VERSION=2.11.1 +VERSION=2.12.0 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -for lib in common state packages login-manager single-user steam-desktop steam-machine fremont-poweroff vram-booster hdmi-refresh nvidia controllers cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut steam-game update-notifier first-login menu backend; do +for lib in common state packages login-manager single-user steam-desktop steam-machine fremont-poweroff vram-booster hdmi-refresh nvidia gamescope-nvidia controllers cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut steam-game update-notifier first-login menu backend; do # shellcheck source=/dev/null source "$SCRIPT_DIR/lib/$lib.sh" done diff --git a/ui/qml/AppState.qml b/ui/qml/AppState.qml index 6148d9c..a57a51b 100644 --- a/ui/qml/AppState.qml +++ b/ui/qml/AppState.qml @@ -65,7 +65,8 @@ Item { var w = {}; for (var i = 0; i < items.length; i++) w[items[i].id] = items[i].kind === "action" ? false : items[i].wanted; want = w; - boot = nowOn("boot") ? "desktop" : "gamescope"; + // (What the PC has, or what the backend ticked: "Gaming on NVIDIA" moving to the conversion keeps how Steam started.) + boot = want.boot ? "desktop" : "gamescope"; if (sel > rows.length) sel = 0; } Connections { target: backend; function onStatusChanged() { if (app.screen === "menu" || app.screen === "done") app.syncFromStatus(); } } @@ -101,7 +102,8 @@ Item { if (it.kind !== "toggle") continue; if (hidden(it)) { if (it.on) p.push({ id: it.id, action: "off" }); continue; } if (it.parent && !want[it.parent]) { if (it.on) p.push({ id: it.id, action: "off" }); continue; } - if (want[it.id] && !it.on) p.push({ id: it.id, action: "on", isNew: !!it["new"] }); + // (An item that is off but flagged "update" replaces what the PC has: the conversion for "Gaming on NVIDIA".) + if (want[it.id] && !it.on) p.push({ id: it.id, action: it.update ? "update" : "on", isNew: !!it["new"] }); else if (!want[it.id] && it.on) p.push({ id: it.id, action: "off" }); else if (want[it.id] && (reapply || it.update)) p.push({ id: it.id, action: reapply ? "again" : "update" }); }