diff --git a/.SRCINFO b/.SRCINFO index 6063131..59389f0 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches pkgver = 2.10.1 - pkgrel = 1 + pkgrel = 2 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 @@ -98,6 +98,8 @@ pkgbase = vortex-linux source = 0039-linux-lutris-store.patch source = 0040-linux-bottles-store.patch source = 0041-linux-plugin-folder-prefix.patch + source = 0042-linux-wine-prefix-setting.patch + source = 0043-linux-steam-prefix-any-library.patch source = vortex.sh source = vortex.desktop source = README.md @@ -154,6 +156,8 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/CHANGELOG.md b/CHANGELOG.md index c68653b..897fb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- Game settings has a Wine tab for Windows games: it shows the prefix Vortex + uses and where it came from, and you can choose another folder. Vortex checks + that the folder is a prefix before using it. Steam, Heroic, Lutris and Bottles + still start their games in their own prefix, so a different folder there only + moves the game's INI and plugin files and where tools run. The error message + used to point at a button on the Dashboard that the current layout doesn't + have. (#22) +- Steam games whose Proton prefix is in another Steam library than the game, + for example after moving the game to an SD card, no longer fail with "Wine + prefix is unavailable" or "Proton version is not installed". A game that really + has no prefix yet now gets a message asking to start it once from Steam. + ## 2.10.1-1 - 2026-11-05 Automatic update to Vortex 2.10.1, published after the usual checks passed. Upstream commit `0514276ed038745a059cf5230b289df6b576c87d`. The Linux patches and packaging are unchanged since 2.10.0-1. diff --git a/PKGBUILD b/PKGBUILD index 3213e7b..e0a2b86 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -2,7 +2,7 @@ pkgname=vortex-linux pkgver=2.10.1 -pkgrel=1 +pkgrel=2 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux" @@ -142,6 +142,8 @@ _patches=( '0039-linux-lutris-store.patch' '0040-linux-bottles-store.patch' '0041-linux-plugin-folder-prefix.patch' + '0042-linux-wine-prefix-setting.patch' + '0043-linux-steam-prefix-any-library.patch' ) source=( @@ -219,6 +221,8 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' diff --git a/README.md b/README.md index 1f3c9ba..1662b21 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Vortex for Linux [![Build status](https://github.com/Arkazzae/VortexLinux/actions/workflows/upstream-compatibility.yml/badge.svg)](https://github.com/Arkazzae/VortexLinux/actions/workflows/upstream-compatibility.yml) +[![Latest release](https://img.shields.io/github/v/release/Arkazzae/VortexLinux?label=release)](https://github.com/Arkazzae/VortexLinux/releases/latest) ![The folder Skyrim reads through Proton, C:\users\steamuser\Documents\My Games\Skyrim Special Edition, turns into the folder Vortex for Linux writes to: ~/.local/share/Steam/steamapps/compatdata/489830/pfx/drive_c/users/steamuser/Documents/My Games/Skyrim Special Edition](docs/hero.webp) @@ -8,179 +9,127 @@ This repo does the other thing: it patches Vortex to actually understand Linux, and builds it as a native app. -## What it does +**[Install](#install)** · [Before your first game](#before-you-add-your-first-game) · [Build it yourself](#building-from-source) · [Report a bug](#something-broken) -- Finds native and Flatpak Steam libraries and selects the active game’s Proton prefix -- Maps `Documents` / `AppData` into the right prefix, so mods and saves land where the game actually looks -- Discovers Epic, GOG and Amazon games in native and Flatpak Heroic installations -- Launches games through Steam or Heroic -- Experimental: recognises Windows games installed through Lutris or Bottles, deploys into their prefix and launches them through the launcher -- Runs Windows modding tools in the game’s Wine/Proton prefix, including calls made by extensions -- Reads and writes Windows INI files while preserving encoding, comments and hardlinks -- Builds Bethesda plugin management with native Linux LOOT -- Deploys and purges hardlinked mods without leaving stale files behind -- Resolves Windows path casing consistently and keeps staging files safe when external changes are detected +## What works -These are general Linux fixes, not per-game hacks. Game-specific Vortex extensions can still carry their own Windows-only assumptions, so some games may still need fixes. +- **Steam**, native or Flatpak. Vortex finds your libraries and each game's Proton prefix. +- **Heroic**, native or Flatpak, with your Epic, GOG and Amazon games. +- **Lutris and Bottles**, as an [experiment](#lutris-and-bottles-experimental). +- **Files land where the game looks.** `Documents` and `AppData` map into the game's prefix, so saves, INI files and plugin lists end up in the right place. +- **Windows modding tools** such as xEdit or BodySlide run in the game's own Wine or Proton prefix. +- **LOOT** sorts Bethesda plugins natively. +- **Deploy and purge** use hardlinks and leave no stale files behind. -### What runs where +Vortex itself runs as a normal Linux app: the interface, downloads, FOMOD installers, deployment and LOOT. Only the Windows-only tools go through Wine. -| Part | Runs as | -|---|---| -| Vortex itself: UI, downloads, installers, deployment | native Linux process | -| Game discovery and launch through Steam, Heroic, Lutris, Bottles | native, through each launcher | -| Mod deployment, INI edits, LOOT sorting, FOMOD installers | native | -| Windows-only modding tools such as xEdit or BodySlide | the game's own Wine or Proton prefix | -| Windows .NET runtimes those tools need | bundled, loaded through Wine | -| Vortex's few Windows-only helpers, and tools run with no game selected | a separate Wine prefix under `~/.local/share/vortex-linux` | +> [!NOTE] +> These are general Linux fixes, not per-game hacks. A game's own Vortex extension can still assume Windows here and there, so some games may need a fix of their own. ## Upstream is catching up 🎉 -Vortex itself is slowly becoming more Linux-friendly. Since 2.7.1, the extension API's `util.epicGamesLauncher` is a shim over `GameStoreHelper` instead of `undefined` everywhere except Windows, so our Epic API fallback patch is gone 🎊. - -2.9.0 goes further: Vortex now builds LOOT on Linux itself, with a libloot made for Linux, so we no longer compile libloot from source and our LOOT patch shrank to fixing file name casing and the Proton folder. Reading game versions from `.exe` files is plain TypeScript now too, so it works the same on every platform. - -2.10.0 brings three Linux fixes of its own: Vortex finds the drive a folder sits on, so moving the staging folder and the folder it suggests work across mounted drives ([#24375](https://github.com/Nexus-Mods/Vortex/pull/24375)), it accepts installer instructions that separate folders with backslashes ([#24378](https://github.com/Nexus-Mods/Vortex/pull/24378)), and the "Handle Mod Manager Download buttons" setting (`nxm://` links) is no longer greyed out on Linux ([#24387](https://github.com/Nexus-Mods/Vortex/pull/24387)). The first one made two of our patches smaller. - -Every fix that lands upstream is one less patch to carry here, and the goal is for this repo to shrink. +Vortex itself keeps getting better at Linux. Since 2.9 it builds LOOT for Linux on its own, and 2.10 fixed staging folders on mounted drives, installers that use backslashes and the greyed-out `nxm://` setting. Every fix that lands upstream is one less patch to carry here. The goal is for this repo to shrink. ## Install -Grab the [latest release](https://github.com/Arkazzae/VortexLinux/releases/latest). What changed in each release is in [CHANGELOG.md](CHANGELOG.md). - -### Arch - -```sh -sha256sum --check vortex-linux-*.pkg.tar.zst.sha256 -sudo pacman -U ./vortex-linux-*.pkg.tar.zst -``` - -### Debian and Ubuntu - -For Ubuntu 24.04 or newer and Debian 13 or newer: - -```sh -sha256sum --check vortex-linux_*_amd64.deb.sha256 -sudo apt install ./vortex-linux_*_amd64.deb -``` - -apt pulls in Wine and the libraries Vortex needs, and Vortex shows up in your app menu. Like the AppImage, the package brings its own Linux .NET runtime and the Windows .NET runtimes modding tools need. To update, install the newer `.deb` the same way. +Download the file for your distribution from the [latest release](https://github.com/Arkazzae/VortexLinux/releases/latest). [CHANGELOG.md](CHANGELOG.md) says what changed. -Debian 12 and Ubuntu 22.04 are too old: the FOMOD installer Vortex ships needs glibc 2.38, so apt refuses the package there. The AppImage needs the same. - -### Fedora and openSUSE +| Distribution | Install with | +|---|---| +| Arch | `sudo pacman -U ./vortex-linux-*.pkg.tar.zst` | +| Debian 13+, Ubuntu 24.04+ | `sudo apt install ./vortex-linux_*_amd64.deb` | +| Fedora 43+ | `sudo dnf install ./vortex-linux-*-x86_64.rpm` | +| openSUSE Leap 16, Tumbleweed | `sudo zypper install --allow-unsigned-rpm ./vortex-linux-*-x86_64.rpm` | +| Anything else | the [AppImage](#appimage) | -For Fedora 43 or newer, openSUSE Leap 16 and Tumbleweed: +The package manager pulls in Wine and everything else Vortex needs, and Vortex shows up in your app menu. You never have to install .NET yourself. Vortex tells you when a newer release is out; install it the same way. -```sh -sha256sum --check vortex-linux-*-x86_64.rpm.sha256 -sudo dnf install ./vortex-linux-*-x86_64.rpm # Fedora -sudo zypper install --allow-unsigned-rpm ./vortex-linux-*-x86_64.rpm # openSUSE -``` +Every file has a `.sha256` next to it, and `sha256sum --check .sha256` verifies the download. The RPM isn't signed, which is why zypper needs `--allow-unsigned-rpm`. -The package works like the Debian one: the package manager pulls in Wine and the libraries, and the .NET runtimes come with it. It isn't signed, which is why zypper needs `--allow-unsigned-rpm`; the checksum is how you verify the download. +> [!NOTE] +> Debian 12 and Ubuntu 22.04 are too old. Vortex's FOMOD installer needs glibc 2.38, and that goes for the AppImage too. -### Everything else (AppImage) +### AppImage Install Wine from your package manager first, then: ```sh -sha256sum --check Vortex-*.AppImage.sha256 chmod +x Vortex-*.AppImage ./Vortex-*.AppImage ``` -The AppImage bundles the Linux .NET 9 runtime Vortex probes for at startup, plus the Windows .NET runtimes modding tools need, so you don't need .NET installed system-wide. Wine you do need. +.NET is inside the AppImage, so Wine is the only thing you install yourself. Gear Lever or AppImageLauncher add it to your app menu and can update it in place (so can `appimageupdatetool`). -No FUSE 2 on your system? Run it as: +For the download buttons on Nexus Mods, turn on **Handle Mod Manager Download buttons** in Vortex's settings. Vortex then registers the AppImage for `nxm://` links and keeps that pointing at the file wherever you put it. This needs `xdg-utils`, which desktop distributions already ship. -```sh -./Vortex-*.AppImage --appimage-extract-and-run -``` +> [!TIP] +> No FUSE 2 on your system? Start it with `./Vortex-*.AppImage --appimage-extract-and-run`. + +## Before you add your first game -AppImages don't add themselves to your app menu; Gear Lever or AppImageLauncher handle that. For Nexus Mods download links, turn on "Handle Nexus Links" in Vortex: the AppImage then registers itself as the `nxm://` handler and keeps pointing at wherever the AppImage file is. This uses `xdg-settings` from xdg-utils, which desktop distributions ship with their browsers. +1. **Start the game once from Steam or Heroic.** That first run is when Proton creates the prefix and the game creates its config and save folders. Until they exist, Vortex has nothing to line up with. +2. **Keep the staging folder on the same filesystem as the game.** Mods are deployed as hardlinks, and hardlinks can't cross filesystems. `Settings → Mods` suggests a folder that works. -The AppImage embeds [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) information pointing at this repository's latest release, so Gear Lever, AppImageLauncher and `appimageupdatetool` can check for and apply updates in place. Vortex also shows a notification when a newer Linux release is published; the older builds from `recipes/` stay pinned and never update. +> [!CAUTION] +> Don't move or delete a staging folder that already has mods in it by hand. Change the path in `Settings → Mods` and let Vortex move the mods. -## Before you add your first game +### Which Wine prefix Vortex uses -**Launch the game once through Steam or Heroic.** Proton and the game create their real config and save directories on that first run; if you point Vortex at a prefix that doesn't exist yet, nothing will line up. +The prefix follows the game you've selected: its Proton prefix on Steam, the Wine, Proton or UMU you set up in Heroic, the Lutris prefix, or the bottle. Tools run with the same Wine or Proton. A game you added by hand starts in Wine's default prefix, `$WINEPREFIX` or `~/.wine`, and Linux-native games from Heroic keep their native paths. -**Keep staging and the game on the same filesystem.** Hardlinks can't cross filesystems. Vortex suggests a good folder under `Settings → Mods`. If you already have mods in an old staging folder, use Vortex's transfer flow. Don't move or delete it by hand. +`Game settings → Wine` shows the prefix in use and lets you choose another. Steam, Heroic, Lutris and Bottles still start their games in their own prefix, so a different folder there only moves the game's INI and plugin files and where tools run. -The selected game determines which prefix Vortex uses. Steam’s per-game Proton -selection takes precedence over its default selection. Heroic’s game settings -select Wine, Proton or UMU; Flatpak tools run inside the Heroic Flatpak. +Vortex's own Windows helpers, and tools started with no game selected, use a separate prefix in `~/.local/share/vortex-linux/wineprefix`. -A game added by hand runs in Wine's default prefix, `$WINEPREFIX` or `~/.wine`. -For another prefix, edit the game on the Dashboard and set `WINEPREFIX` under -Environment Variables. -`VORTEX_COMPAT_PREFIXES` can list additional prefixes, separated by colons, for -lookups without a selected game. If several prefixes match, Vortex reports the -ambiguity instead of choosing by modification time. +A few environment variables help when Vortex can't find something: + +| Variable | Set it when | +|---|---| +| `HEROIC_BINARY` | Heroic is an AppImage outside `PATH`. Open it once before Vortex scans for games. | +| `LUTRIS_BINARY` | `lutris` isn't on `PATH` | +| `BOTTLES_CLI_BINARY` | `bottles-cli` isn't on `PATH` | +| `VORTEX_COMPAT_PREFIXES` | you want more prefixes searched when no game is selected (colon-separated). If several match, Vortex says so instead of guessing. | -If Heroic is an AppImage outside `PATH`, set `HEROIC_BINARY` to its executable. -Launch it once before scanning for games. Linux-native Heroic games keep their -native configuration paths. +### Lutris and Bottles (experimental) -**Lutris and Bottles.** Games that use Lutris' Wine runner work -too. If Vortex doesn't find the game by itself, set its folder manually, as it -appears inside the Lutris prefix (also when that folder is a symlink to another -disk). Vortex recognises that it belongs to Lutris, uses the game's Lutris -prefix, starts it through Lutris and runs tools with the Wine version selected -for it in Lutris. Tools need a Wine build for now; games set to Proton in Lutris -can be deployed to and launched, but not run tools. Set `LUTRIS_BINARY` if -`lutris` isn't on `PATH`. +Both are new and lightly tested, so please report what breaks. -Bottles works the same way: set the game folder inside the bottle, or the folder -of a program you added to a bottle. Vortex uses the bottle as the prefix, starts -a program added in Bottles through `bottles-cli`, and runs tools with the -bottle's runner. Bottles keeps bottles by name, so rename bottles that share a -name. Flatpak Bottles only sees folders its sandbox allows. Set -`BOTTLES_CLI_BINARY` if `bottles-cli` isn't on `PATH`. Both are new and lightly -tested; please report what breaks. +If Vortex doesn't find the game by itself, set its folder by hand, as it appears inside the Lutris prefix or the bottle (a symlink to another disk is fine). Vortex recognises whose game it is, uses that prefix, starts the game through the launcher and runs tools with the Wine version chosen there. -The separate prefix for Vortex’s own helper processes still defaults to -`${XDG_DATA_HOME:-$HOME/.local/share}/vortex-linux/wineprefix`. +- **Lutris:** tools need a Wine runner for now. Games set to Proton can be deployed to and launched, but can't run tools. +- **Bottles:** the folder of a program you added to a bottle works too. Bottles are looked up by name, so rename any that share one. Flatpak Bottles only sees the folders its sandbox allows. -## Building from source (Arch) +## Building from source + +On Arch: ```sh git clone https://github.com/Arkazzae/VortexLinux.git -cd vortex-linux +cd VortexLinux scripts/prepare-arch-build.sh cd dist/arch-build makepkg -si ``` -The build pins Vortex to an exact upstream commit and downloads the source, Node dependencies, Electron and the Microsoft runtime files. First build is a big one. +The build pins Vortex to an exact upstream commit and downloads its source, the Node dependencies, Electron and the Microsoft runtimes. The first build takes a while. ### Older Vortex versions -We support **1.16.9** and **2.6.2** for collections that need an older Vortex, -alongside the [latest release](https://github.com/Arkazzae/VortexLinux/releases/latest). -Need another version? [Open an issue](https://github.com/Arkazzae/VortexLinux/issues/new) -and tell us which version your collection needs. +Some collections need an older Vortex, so **1.16.9** and **2.6.2** are supported next to the latest release. They stay on their version and never update. Need a different one? [Open an issue](https://github.com/Arkazzae/VortexLinux/issues/new) and say which version your collection asks for. -To build one, fork this repo and run **Actions → Build a selected Vortex version**, -then download the files from the completed run. Or build locally: +To get one, fork this repo, run **Actions → Build a selected Vortex version** and download the files from the finished run. Or build it locally: ```sh python3 scripts/build-version.py --version 2.6.2 ``` -You'll find the files in `dist/versions/2.6.2/artifacts/`. +The files end up in `dist/versions/2.6.2/artifacts/`. ## How it's tested -Every change goes through the same pipeline before it can become a release: - -1. **The patches apply.** CI checks out the pinned Vortex commit and applies every patch in order. -2. **The tests pass.** It runs the tests that ship with the patches, typechecks the renderer, and builds and tests the native LOOT binding. -3. **Every package builds.** The Arch package is built in a clean Arch container, and the AppImage, `.deb` and `.rpm` are built from that package. -4. **Every package starts.** The AppImage in a clean Ubuntu 24.04 container, the `.deb` on Ubuntu 24.04, Ubuntu 26.04 and Debian 13, the `.rpm` on Fedora 43 and 44, openSUSE Leap 16 and Tumbleweed. Each container has no system .NET; the packages are installed with only their own dependencies, and their libraries are checked against the distribution. Then the bundled checks run (the .NET probe, the Windows .NET runtimes through Wine, a FOMOD install, LOOT sorting, INI writes, prefix selection, Heroic discovery, tool arguments, hardlink purge), Vortex boots under Xvfb, the main page has to render and Vortex has to find .NET from inside the app. The packages must also uninstall cleanly. +Nothing becomes a release until CI has applied every patch to the pinned Vortex commit, run the tests and built every package. Each package is then installed in a clean container of every distribution it supports, with no system .NET, and put to work: a FOMOD install, LOOT sorting, INI writes, a hardlink purge, Windows tools through Wine. Vortex has to boot, render its main page and uninstall cleanly. -### Run it yourself +To run the same checks yourself: ```sh scripts/test-package.sh dist/Vortex-*.AppImage # the AppImage check @@ -189,15 +138,18 @@ scripts/check-upstream-compatibility.sh --ref latest --keep # patches against t scripts/run-upstream-tests.sh /path/to/patched-vortex # the test suite ``` -`--ref pinned` checks the patches against the pinned version instead, and `--keep` keeps the prepared source for the test suite. +`--ref pinned` checks against the pinned version instead, and `--keep` keeps the prepared source for the test suite. ## Something broken? -Open an issue with the game, the store, where it's installed, how to reproduce it, and the relevant chunk of `~/.config/Vortex/vortex.log`. Scrub your Nexus tokens and private download links out of the log first. +[Open an issue](https://github.com/Arkazzae/VortexLinux/issues/new) with the game, the store, where it's installed, how to reproduce it, and the relevant part of `~/.config/Vortex/vortex.log`. + +> [!WARNING] +> Remove your Nexus tokens and private download links from the log before you post it. ## Thanks -Most of what got fixed here started as somebody's bug report. The people behind those reports and pull requests are listed in [THANKS.md](THANKS.md). +Most of what got fixed here started as somebody's bug report. The people behind those reports and pull requests are in [THANKS.md](THANKS.md). ## License diff --git a/patches/0038-linux-default-wine-prefix.patch b/patches/0038-linux-default-wine-prefix.patch index 5a53498..99889c0 100644 --- a/patches/0038-linux-default-wine-prefix.patch +++ b/patches/0038-linux-default-wine-prefix.patch @@ -28,7 +28,7 @@ diff --git a/src/renderer/src/util/compatibilityPaths.ts b/src/renderer/src/util return Object.assign( new Error( - "The selected game's Wine/Proton user profile is unavailable. Launch the game once, or configure its WINEPREFIX.", -+ "The selected game's Wine/Proton user profile is unavailable. Launch the game once, or edit the game on the Dashboard and set WINEPREFIX under Environment Variables.", ++ "The selected game's Wine/Proton user profile is unavailable. Launch the game once, or choose the prefix in Game settings under Wine.", ), { code: "ENOENT" }, ); @@ -78,7 +78,7 @@ diff --git a/src/renderer/src/util/linux/gameTools.test.ts b/src/renderer/src/ut + const manual = { path: path.join(root, "Games/Example"), executable: "Example.exe" }; + const game = path.join(manual.path, manual.executable); + write(game, "MZ"); -+ await expect(resolveGameTool(game, [], {}, manual)).rejects.toThrow(/Environment Variables/); ++ await expect(resolveGameTool(game, [], {}, manual)).rejects.toThrow(/Game settings/); + fs.mkdirSync(path.join(root, ".wine/drive_c"), { recursive: true }); + resetCompatibilityPathCache(); + const command = await resolveGameTool(game, [], {}, manual); @@ -97,7 +97,7 @@ diff --git a/src/renderer/src/util/linux/gameTools.ts b/src/renderer/src/util/li if (!prefix || !fs.existsSync(path.join(prefix, "drive_c"))) { throw new Error( - "The game's Wine prefix is unavailable. Launch the game once or configure WINEPREFIX for this tool.", -+ "The game's Wine prefix is unavailable. Launch the game once, or edit the game or tool on the Dashboard and set WINEPREFIX under Environment Variables.", ++ "The game's Wine prefix is unavailable. Launch the game once, choose the prefix in Game settings under Wine, or set WINEPREFIX for a tool with Edit on the Tools page.", ); } if (game?.store === "steam") { diff --git a/patches/0042-linux-wine-prefix-setting.patch b/patches/0042-linux-wine-prefix-setting.patch new file mode 100644 index 0000000..fa16c26 --- /dev/null +++ b/patches/0042-linux-wine-prefix-setting.patch @@ -0,0 +1,433 @@ +diff --git a/src/renderer/src/extensions/gamemode_management/index.ts b/src/renderer/src/extensions/gamemode_management/index.ts +--- a/src/renderer/src/extensions/gamemode_management/index.ts ++++ b/src/renderer/src/extensions/gamemode_management/index.ts +@@ -37,6 +37,7 @@ import local from "../../util/local"; + import { showError } from "../../util/message"; + import { resolveNativeExecutable, resolvePlatformPath } from "../../util/nativeExecutable"; + import opn from "../../util/opn"; ++import { usesWinePrefix } from "../../util/compatibilityPaths"; + import { activeGameId, activeProfile } from "../../util/selectors"; + import { getSafe } from "../../util/storeHelper"; + import { batchDispatch } from "../../util/util"; +@@ -910,6 +911,14 @@ function init(context: IExtensionContext): boolean { + }, + ); + ++ context.registerSettings( ++ "Wine", ++ LazyComponent(() => require("./views/WinePrefixSettings")), ++ undefined, ++ () => usesWinePrefix(), ++ 80, ++ ); ++ + context.registerDashlet( + "Recently Managed", + 2, +diff --git a/src/renderer/src/extensions/gamemode_management/views/WinePrefixSettings.tsx b/src/renderer/src/extensions/gamemode_management/views/WinePrefixSettings.tsx +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/extensions/gamemode_management/views/WinePrefixSettings.tsx +@@ -0,0 +1,167 @@ ++import { mdiFolderOpen } from "@mdi/js"; ++import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; ++import { useTranslation } from "react-i18next"; ++import { useDispatch, useSelector } from "react-redux"; ++ ++import { MainContext } from "@/contexts"; ++import { Alert } from "@/ui/components/alert/Alert"; ++import { Button } from "@/ui/components/button/Button"; ++import { TextField } from "@/ui/components/form/text_field/TextField"; ++import { Typography } from "@/ui/components/typography/Typography"; ++ ++import { detectedGamePrefix } from "../../../util/compatibilityPaths"; ++import { ++ launcherName, ++ resolvePrefixInput, ++ winePrefixParameters, ++ winePrefixProblem, ++} from "../../../util/linux/winePrefixSetting"; ++import { activeGameId } from "../../../util/selectors"; ++import { setGameParameters } from "../actions/settings"; ++import { currentGame, currentGameDiscovery } from "../selectors"; ++ ++/** Shows the prefix a game runs in and lets the user point it somewhere else. */ ++export default function WinePrefixSettings() { ++ const { t } = useTranslation(["common"]); ++ const { api } = useContext(MainContext); ++ const dispatch = useDispatch(); ++ const gameId = useSelector(activeGameId); ++ const game = useSelector(currentGame); ++ const discovery = useSelector(currentGameDiscovery); ++ const store = discovery?.store || undefined; ++ const saved = discovery?.environment?.WINEPREFIX ?? ""; ++ const [draft, setDraft] = useState(saved); ++ const [error, setError] = useState(undefined); ++ ++ const detected = useMemo(() => { ++ try { ++ const executable = discovery?.executable ?? game?.executable; ++ return detectedGamePrefix({ ...discovery, executable }) ?? undefined; ++ } catch { ++ return undefined; ++ } ++ }, [discovery, game]); ++ ++ useEffect(() => { ++ setDraft(saved); ++ setError(undefined); ++ }, [gameId, saved]); ++ ++ const apply = useCallback( ++ (input: string) => { ++ setDraft(input); ++ const clearing = input.trim() === ""; ++ const prefix = clearing ? undefined : resolvePrefixInput(input); ++ // A folder that can't work would fail every file operation in it, so it is never stored. ++ const problem = clearing ++ ? undefined ++ : prefix === undefined ++ ? "not-a-prefix" ++ : winePrefixProblem(prefix, store); ++ if (problem !== undefined) { ++ setError( ++ problem === "not-a-prefix" ++ ? t("This folder is not a Wine prefix: it has no drive_c folder.") ++ : t("Proton needs the folder named pfx, inside the game's compatdata folder."), ++ ); ++ return; ++ } ++ setError(undefined); ++ if ((prefix ?? "") !== saved) { ++ dispatch( ++ setGameParameters(gameId, winePrefixParameters(discovery, game?.environment, prefix)), ++ ); ++ } ++ }, ++ [dispatch, discovery, game, gameId, saved, store, t], ++ ); ++ ++ const browse = useCallback(() => { ++ void api.selectDir({ defaultPath: saved || detected }).then((selected) => { ++ if (selected !== undefined) { ++ apply(selected); ++ } ++ }); ++ }, [api, apply, detected, saved]); ++ ++ const launcher = store !== undefined ? launcherName(store) : undefined; ++ const inUse = saved || detected; ++ const source = ++ saved !== "" ++ ? t("Chosen by you") ++ : launcher !== undefined ++ ? t("Found through {{launcher}}", { replace: { launcher } }) ++ : t("Wine's default prefix"); ++ ++ return ( ++
{ ++ event.preventDefault(); ++ apply(draft); ++ }} ++ > ++
++ ++ {t("Prefix in use")} ++ ++ ++ {inUse ? ( ++ <> ++ ++ {inUse} ++ ++ ++ ++ {source} ++ ++ ++ ) : ( ++ ++ {t("No prefix found yet. Start the game once, or choose one below.")} ++ ++ )} ++ ++ {saved !== "" && detected !== undefined && detected !== saved && ( ++ ++ {t("Detected: {{path}}", { replace: { path: detected } })} ++ ++ )} ++
++ ++ {launcher !== undefined && ( ++ ++ {t( ++ "{{launcher}} starts this game in its own prefix, and Vortex can't change that. " + ++ "A different folder here only moves the game's INI and plugin files and where " + ++ "tools run, so those changes won't reach the game unless it is the game's real prefix.", ++ { replace: { launcher } }, ++ )} ++ ++ )} ++ ++ apply(draft)} ++ onChange={(event) => setDraft(event.target.value)} ++ /> ++ ++
++ ++ ++ {saved !== "" && ( ++ ++ )} ++
++ ++ ); ++} +diff --git a/src/renderer/src/util/compatibilityPaths.ts b/src/renderer/src/util/compatibilityPaths.ts +--- a/src/renderer/src/util/compatibilityPaths.ts ++++ b/src/renderer/src/util/compatibilityPaths.ts +@@ -89,6 +89,17 @@ function resolveGamePrefix(game: CompatibilityGame): string | null | undefined { + return undefined; + } + ++/** Whether the game runs in Wine at all, so it has a prefix to show and change. */ ++export function usesWinePrefix(game = gameProvider?.()): boolean { ++ return process.platform === "linux" && game !== undefined && selectedGamePrefix(game) !== null; ++} ++ ++/** The prefix Vortex finds for a game on its own, whatever the user chose instead. */ ++export function detectedGamePrefix(game: CompatibilityGame): string | null | undefined { ++ const { WINEPREFIX: _chosen, ...environment } = game.environment ?? {}; ++ return resolveGamePrefix({ ...game, environment }); ++} ++ + /** + * A game added by hand has no store to ask, so it lives where plain Wine runs + * everything: $WINEPREFIX, or ~/.wine when that isn't set. +diff --git a/src/renderer/src/util/linux/winePrefixSetting.test.ts b/src/renderer/src/util/linux/winePrefixSetting.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/linux/winePrefixSetting.test.ts +@@ -0,0 +1,143 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; ++ ++import { ++ detectedGamePrefix, ++ registerCompatibilityStore, ++ resetCompatibilityPathCache, ++ selectedGamePrefix, ++ usesWinePrefix, ++} from "../compatibilityPaths"; ++import { ++ launcherName, ++ resolvePrefixInput, ++ winePrefixParameters, ++ winePrefixProblem, ++} from "./winePrefixSetting"; ++ ++describe.runIf(process.platform === "linux")("Wine prefix setting", () => { ++ let root: string; ++ ++ beforeEach(() => { ++ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-wine-prefix-")); ++ vi.stubEnv("HOME", root); ++ vi.stubEnv("WINEPREFIX", ""); ++ resetCompatibilityPathCache(); ++ }); ++ ++ afterEach(() => { ++ vi.unstubAllEnvs(); ++ resetCompatibilityPathCache(); ++ fs.rmSync(root, { force: true, recursive: true }); ++ }); ++ ++ const windowsGame = () => { ++ const gamePath = path.join(root, "Games/Example"); ++ fs.mkdirSync(gamePath, { recursive: true }); ++ fs.writeFileSync(path.join(gamePath, "Example.exe"), "MZ"); ++ return { path: gamePath, executable: "Example.exe" }; ++ }; ++ ++ it("expands the home folder and refuses relative paths", () => { ++ expect(resolvePrefixInput("~/.wine")).toBe(path.join(root, ".wine")); ++ expect(resolvePrefixInput(" $HOME/prefixes/game/ ")).toBe(path.join(root, "prefixes/game")); ++ expect(resolvePrefixInput("/mnt/prefix/")).toBe("/mnt/prefix"); ++ expect(resolvePrefixInput("prefixes/game")).toBeUndefined(); ++ }); ++ ++ it("recognises a prefix by its drive_c folder", () => { ++ expect(winePrefixProblem(root, undefined)).toBe("not-a-prefix"); ++ fs.mkdirSync(path.join(root, "drive_c")); ++ expect(winePrefixProblem(root, undefined)).toBeUndefined(); ++ }); ++ ++ it("wants Proton's pfx folder for a Steam game", () => { ++ const pfx = path.join(root, "compatdata/1/pfx"); ++ fs.mkdirSync(path.join(root, "drive_c")); ++ fs.mkdirSync(path.join(pfx, "drive_c"), { recursive: true }); ++ expect(winePrefixProblem(root, "steam")).toBe("not-for-proton"); ++ expect(winePrefixProblem(root, "epic")).toBeUndefined(); ++ expect(winePrefixProblem(pfx, "steam")).toBeUndefined(); ++ }); ++ ++ it("names the program that starts a store's games", () => { ++ expect(launcherName("steam")).toBe("Steam"); ++ expect(launcherName("gog")).toBe("Heroic"); ++ expect(launcherName("lutris")).toBe("Lutris"); ++ }); ++ ++ it("detects the prefix without the one the user chose", () => { ++ const game = windowsGame(); ++ expect(detectedGamePrefix(game)).toBeUndefined(); ++ fs.mkdirSync(path.join(root, ".wine/drive_c"), { recursive: true }); ++ const chosen = { ...game, environment: { WINEPREFIX: "/chosen", OTHER: "1" } }; ++ expect(detectedGamePrefix(chosen)).toBe(path.join(root, ".wine")); ++ expect(selectedGamePrefix(chosen)).toBe("/chosen"); ++ }); ++ ++ it("keeps the game's other variables when setting and clearing the prefix", () => { ++ const set = winePrefixParameters( ++ { environment: { DXVK_HUD: "1" }, envCustomized: true }, ++ { SteamAppId: "1" }, ++ "/prefix", ++ ); ++ expect(set).toEqual({ ++ environment: { DXVK_HUD: "1", WINEPREFIX: "/prefix" }, ++ envCustomized: true, ++ }); ++ ++ expect(winePrefixParameters(set, { SteamAppId: "1" }, undefined)).toEqual({ ++ environment: { DXVK_HUD: "1" }, ++ envCustomized: true, ++ }); ++ }); ++ ++ it("starts from the extension's variables until the user customised them", () => { ++ expect( ++ winePrefixParameters({ environment: { STALE: "1" } }, { SteamAppId: "1" }, "/prefix"), ++ ).toEqual({ ++ environment: { SteamAppId: "1", WINEPREFIX: "/prefix" }, ++ envCustomized: true, ++ }); ++ }); ++ ++ it("stops counting as customised once the prefix is cleared again", () => { ++ const set = winePrefixParameters({}, { SteamAppId: "1" }, "/prefix"); ++ expect(winePrefixParameters(set, { SteamAppId: "1" }, undefined)).toEqual({ ++ environment: { SteamAppId: "1" }, ++ envCustomized: false, ++ }); ++ }); ++ ++ it("makes the game use the chosen prefix", () => { ++ const game = windowsGame(); ++ const prefix = path.join(root, "chosen"); ++ expect(selectedGamePrefix(game)).toBeUndefined(); ++ ++ const { environment } = winePrefixParameters({}, undefined, prefix); ++ ++ expect(selectedGamePrefix({ ...game, environment })).toBe(prefix); ++ }); ++ ++ it("offers the setting for every Windows game, launcher or not", () => { ++ registerCompatibilityStore("test-launcher", () => undefined); ++ const game = windowsGame(); ++ expect(usesWinePrefix(game)).toBe(true); ++ expect(usesWinePrefix({ ...game, store: "steam" })).toBe(true); ++ expect(usesWinePrefix({ ...game, store: "test-launcher" })).toBe(true); ++ expect(usesWinePrefix(undefined)).toBe(false); ++ ++ const native = path.join(root, "Games/Native"); ++ fs.mkdirSync(native); ++ fs.writeFileSync(path.join(native, "run.sh"), "#!/bin/sh\n"); ++ expect(usesWinePrefix({ path: native, executable: "run.sh" })).toBe(false); ++ }); ++ ++ it("keeps the setting reachable after a prefix was chosen", () => { ++ const game = windowsGame(); ++ expect(usesWinePrefix({ ...game, environment: { WINEPREFIX: "/gone" } })).toBe(true); ++ }); ++}); +diff --git a/src/renderer/src/util/linux/winePrefixSetting.ts b/src/renderer/src/util/linux/winePrefixSetting.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/linux/winePrefixSetting.ts +@@ -0,0 +1,61 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { isEqual } from "lodash"; ++ ++type Environment = Record; ++ ++/** The absolute folder a typed or picked value names; undefined for a relative one. */ ++export function resolvePrefixInput(input: string): string | undefined { ++ const expanded = input.trim().replace(/^(?:~|\$HOME)(?=\/|$)/, os.homedir()); ++ return path.isAbsolute(expanded) ? path.resolve(expanded) : undefined; ++} ++ ++export type WinePrefixProblem = "not-a-prefix" | "not-for-proton"; ++ ++/** Why a folder can't be this game's prefix; undefined when it can. */ ++export function winePrefixProblem( ++ folder: string, ++ store: string | undefined, ++): WinePrefixProblem | undefined { ++ if (!fs.existsSync(path.join(folder, "drive_c"))) { ++ return "not-a-prefix"; ++ } ++ // Proton is given the folder around the prefix and always looks for pfx inside it. ++ return store === "steam" && path.basename(folder) !== "pfx" ? "not-for-proton" : undefined; ++} ++ ++const LAUNCHERS: Record = { ++ amazon: "Heroic", ++ bottles: "Bottles", ++ epic: "Heroic", ++ gog: "Heroic", ++ lutris: "Lutris", ++ steam: "Steam", ++}; ++ ++/** The program that starts games of this store on Linux. */ ++export function launcherName(store: string): string { ++ return LAUNCHERS[store] ?? store; ++} ++ ++/** ++ * What to store for a game to use `prefix`, or the detected one without it. The game's ++ * other variables stay, and envCustomized follows the same rule as the tool edit dialog ++ * because launching a game ignores the stored environment without it. ++ */ ++export function winePrefixParameters( ++ discovery: { environment?: Environment; envCustomized?: boolean }, ++ gameEnvironment: Environment | undefined, ++ prefix: string | undefined, ++): { environment: Environment; envCustomized: boolean } { ++ const original = gameEnvironment ?? {}; ++ const { WINEPREFIX: _previous, ...environment } = discovery.envCustomized ++ ? (discovery.environment ?? {}) ++ : original; ++ if (prefix !== undefined) { ++ environment.WINEPREFIX = prefix; ++ } ++ return { environment, envCustomized: !isEqual(environment, original) }; ++} diff --git a/patches/0043-linux-steam-prefix-any-library.patch b/patches/0043-linux-steam-prefix-any-library.patch new file mode 100644 index 0000000..270f308 --- /dev/null +++ b/patches/0043-linux-steam-prefix-any-library.patch @@ -0,0 +1,274 @@ +diff --git a/src/renderer/src/util/compatibilityPaths.ts b/src/renderer/src/util/compatibilityPaths.ts +--- a/src/renderer/src/util/compatibilityPaths.ts ++++ b/src/renderer/src/util/compatibilityPaths.ts +@@ -55,7 +55,10 @@ export function selectedGamePrefix(game = gameProvider?.()): string | null | und + selectedPrefixCache = { + key, + prefix, +- expires: prefix === undefined ? Date.now() + MISSING_PREFIX_RETRY_MS : Infinity, ++ expires: ++ prefix === null || (prefix !== undefined && fs.existsSync(prefix)) ++ ? Infinity ++ : Date.now() + MISSING_PREFIX_RETRY_MS, + }; + return prefix; + } +diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer/src/util/compatibilitySelection.test.ts +--- a/src/renderer/src/util/compatibilitySelection.test.ts ++++ b/src/renderer/src/util/compatibilitySelection.test.ts +@@ -196,6 +196,52 @@ describe.runIf(process.platform === "linux")("selected compatibility prefix", () + expect(() => resolveWindowsUserPathSync(path.join(documents, relative))).toThrow(/unavailable/); + }); + ++ function gameOnAnotherLibrary(): { gamePath: string; mainPrefix: string } { ++ const main = path.join(home, ".local/share/Steam"); ++ const library = path.join(home, "sdcard"); ++ fs.mkdirSync(path.join(main, "steamapps"), { recursive: true }); ++ fs.writeFileSync( ++ path.join(main, "steamapps/libraryfolders.vdf"), ++ `"libraryfolders"\n{\n"0"\n{\n"path" "${library}"\n}\n}`, ++ ); ++ const gamePath = path.join(library, "steamapps/common/Example"); ++ fs.mkdirSync(gamePath, { recursive: true }); ++ fs.writeFileSync( ++ path.join(library, "steamapps/appmanifest_123.acf"), ++ '"AppState"\n{\n"appid" "123"\n"installdir" "Example"\n}', ++ ); ++ return { gamePath, mainPrefix: path.join(main, "steamapps/compatdata/123/pfx") }; ++ } ++ ++ it("maps game data into a Proton prefix kept in another library than the game", () => { ++ const { gamePath, mainPrefix } = gameOnAnotherLibrary(); ++ const expected = fixture(mainPrefix); ++ resetCompatibilityPathCache(); ++ setCompatibilityGameProvider(() => ({ ++ path: gamePath, ++ store: "steam", ++ executable: "Example.exe", ++ })); ++ expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe(expected); ++ }); ++ ++ it("notices a Proton prefix that appears in another library after the first look", () => { ++ const { gamePath, mainPrefix } = gameOnAnotherLibrary(); ++ resetCompatibilityPathCache(); ++ setCompatibilityGameProvider(() => ({ ++ path: gamePath, ++ store: "steam", ++ executable: "Example.exe", ++ })); ++ const now = vi.spyOn(Date, "now").mockReturnValue(1000); ++ expect(() => resolveWindowsUserPathSync(path.join(documents, relative))).toThrow(/unavailable/); ++ ++ const expected = fixture(mainPrefix); ++ now.mockReturnValue(61_000); ++ expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe(expected); ++ now.mockRestore(); ++ }); ++ + it("keeps native game paths and Vortex state outside Proton", () => { + const gamePath = path.join(home, "native"); + fs.mkdirSync(gamePath); +diff --git a/src/renderer/src/util/linux/gameTools.test.ts b/src/renderer/src/util/linux/gameTools.test.ts +--- a/src/renderer/src/util/linux/gameTools.test.ts ++++ b/src/renderer/src/util/linux/gameTools.test.ts +@@ -197,4 +197,81 @@ describe.runIf(process.platform === "linux")("game tool runner", () => { + ), + ).rejects.toThrow(/differs/); + }); ++ it("finds the Proton prefix in another Steam library than the game", async () => { ++ proton("Proton 10.0"); ++ const elsewhere = path.join(steam, "steamapps/compatdata/42/pfx"); ++ fs.mkdirSync(path.join(elsewhere, "drive_c"), { recursive: true }); ++ fs.rmSync(path.dirname(prefix), { recursive: true }); ++ ++ const command = await resolveGameTool("Tool.exe", [], {}, { path: gamePath, store: "steam" }); ++ ++ expect(command.options.env.WINEPREFIX).toBe(elsewhere); ++ expect(command.options.env.STEAM_COMPAT_DATA_PATH).toBe(path.dirname(elsewhere)); ++ }); ++ it("uses the prefix Proton ran in last when several libraries have one", async () => { ++ proton("Proton 10.0"); ++ const elsewhere = path.join(steam, "steamapps/compatdata/42/pfx"); ++ fs.mkdirSync(path.join(elsewhere, "drive_c"), { recursive: true }); ++ const beside = path.join(path.dirname(prefix), "pfx.lock"); ++ const other = path.join(path.dirname(elsewhere), "pfx.lock"); ++ write(beside, ""); ++ write(other, ""); ++ const launch = async () => { ++ resetCompatibilityPathCache(); ++ const game = { path: gamePath, store: "steam" }; ++ return (await resolveGameTool("Tool.exe", [], {}, game)).options.env.WINEPREFIX; ++ }; ++ ++ fs.utimesSync(beside, 1000, 1000); ++ fs.utimesSync(other, 2000, 2000); ++ expect(await launch()).toBe(elsewhere); ++ ++ fs.utimesSync(beside, 3000, 3000); ++ expect(await launch()).toBe(prefix); ++ }); ++ it("runs a Steam tool in the prefix chosen for the game", async () => { ++ proton("Proton 10.0"); ++ const chosen = path.join(root, "chosen/compatdata/42/pfx"); ++ fs.mkdirSync(path.join(chosen, "drive_c"), { recursive: true }); ++ fs.rmSync(path.dirname(prefix), { recursive: true }); ++ const game = { path: gamePath, store: "steam", environment: { WINEPREFIX: chosen } }; ++ ++ const command = await resolveGameTool("Tool.exe", [], { env: { WINEPREFIX: chosen } }, game); ++ ++ expect(command.executable).toContain("Proton 10.0"); ++ expect(command.options.env.WINEPREFIX).toBe(chosen); ++ expect(command.options.env.STEAM_COMPAT_DATA_PATH).toBe(path.dirname(chosen)); ++ }); ++ it("still refuses a tool prefix that differs from the one chosen for the game", async () => { ++ proton("Proton 10.0"); ++ const chosen = path.join(root, "chosen/compatdata/42/pfx"); ++ const other = path.join(root, "other"); ++ fs.mkdirSync(path.join(chosen, "drive_c"), { recursive: true }); ++ fs.mkdirSync(path.join(other, "drive_c"), { recursive: true }); ++ const game = { path: gamePath, store: "steam", environment: { WINEPREFIX: chosen } }; ++ await expect( ++ resolveGameTool("Tool.exe", [], { env: { WINEPREFIX: other } }, game), ++ ).rejects.toThrow(/differs/); ++ }); ++ it("explains that Proton needs a chosen prefix in a pfx folder", async () => { ++ proton("Proton 10.0"); ++ const chosen = path.join(root, "plain-prefix"); ++ fs.mkdirSync(path.join(chosen, "drive_c"), { recursive: true }); ++ const game = { path: gamePath, store: "steam", environment: { WINEPREFIX: chosen } }; ++ await expect(resolveGameTool("Tool.exe", [], {}, game)).rejects.toThrow(/folder named pfx/); ++ }); ++ it("asks to start a Steam game that has no Proton prefix yet", async () => { ++ proton("Proton 10.0"); ++ const other = path.join(root, "other"); ++ fs.mkdirSync(path.join(other, "drive_c"), { recursive: true }); ++ fs.rmSync(path.dirname(prefix), { recursive: true }); ++ await expect( ++ resolveGameTool( ++ "Tool.exe", ++ [], ++ { env: { WINEPREFIX: other } }, ++ { path: gamePath, store: "steam" }, ++ ), ++ ).rejects.toThrow(/Start the game once from Steam/); ++ }); + }); +diff --git a/src/renderer/src/util/linux/gameTools.ts b/src/renderer/src/util/linux/gameTools.ts +--- a/src/renderer/src/util/linux/gameTools.ts ++++ b/src/renderer/src/util/linux/gameTools.ts +@@ -56,12 +56,31 @@ export async function resolveGameTool( + const library = canonicalPath(path.dirname(installed.steamAppsPath)); + const steam = steamRoots().find((root) => steamLibraries([root]).includes(library)); + if (!steam) throw new Error("Cannot find Steam's configuration for this game library"); +- const proton = await getProtonInfo(steam, installed.steamAppsPath, installed.appId); ++ // A prefix chosen in Game settings replaces the one Steam's files point at. ++ const chosen = game.environment?.WINEPREFIX ? selectedGamePrefix(game) : undefined; ++ if (chosen && path.basename(chosen) !== "pfx") { ++ throw new Error( ++ "Proton can only use a prefix in a folder named pfx, inside the game's compatdata folder. Choose that folder in Game settings under Wine.", ++ ); ++ } ++ const proton = await getProtonInfo( ++ steam, ++ installed.steamAppsPath, ++ installed.appId, ++ chosen ? path.dirname(chosen) : undefined, ++ ); ++ if (!proton.usesProton) ++ throw new Error( ++ "Steam has not set up a Proton prefix for this game yet. Start the game once from Steam.", ++ ); + if (!proton.protonPath) + throw new Error( + "The Proton version selected in Steam is not installed or could not be identified", + ); +- if (options.env?.WINEPREFIX && canonicalPath(prefix) !== canonicalPath(installed.prefix)) { ++ if ( ++ options.env?.WINEPREFIX && ++ canonicalPath(prefix) !== canonicalPath(chosen || installed.prefix) ++ ) { + throw new Error( + "This tool's WINEPREFIX differs from the selected Steam game. Use the game's prefix with Proton.", + ); +diff --git a/src/renderer/src/util/linux/proton.ts b/src/renderer/src/util/linux/proton.ts +--- a/src/renderer/src/util/linux/proton.ts ++++ b/src/renderer/src/util/linux/proton.ts +@@ -3,7 +3,7 @@ import * as path from "node:path"; + + import { parse } from "simple-vdf"; + +-import { steamLibraries } from "./steamLibraries"; ++import { findCompatDataPath, steamLibraries } from "./steamLibraries"; + + export interface IProtonInfo { + usesProton: boolean; +@@ -109,14 +109,17 @@ export async function getProtonInfo( + steamPath: string, + steamAppsPath: string, + appId: string, ++ chosenCompatDataPath?: string, + ): Promise { +- if (!(await detectProtonUsage(steamAppsPath, appId))) return { usesProton: false }; ++ const compatDataPath = ++ chosenCompatDataPath ?? findCompatDataPath(appId, steamAppsPath, steamLibraries([steamPath])); ++ if (!fs.existsSync(getWinePrefixPath(compatDataPath))) return { usesProton: false }; + const protonName = await getConfiguredProtonName(steamPath, appId); + const protonPath = + protonName !== undefined + ? await resolveProtonPath(steamPath, protonName) + : await findLatestProton(steamPath); +- return { usesProton: true, compatDataPath: getCompatDataPath(steamAppsPath, appId), protonPath }; ++ return { usesProton: true, compatDataPath, protonPath }; + } + + export function isWindowsExecutable(filePath: string): boolean { +diff --git a/src/renderer/src/util/linux/steamLibraries.ts b/src/renderer/src/util/linux/steamLibraries.ts +--- a/src/renderer/src/util/linux/steamLibraries.ts ++++ b/src/renderer/src/util/linux/steamLibraries.ts +@@ -47,6 +47,36 @@ export function steamLibraries(roots = steamRoots()): string[] { + return [...libraries]; + } + ++function prefixLastUsed(compatDataPath: string): number { ++ try { ++ // Proton touches this file every time it starts the game. ++ return fs.statSync(path.join(compatDataPath, "pfx.lock")).mtimeMs; ++ } catch { ++ return 0; ++ } ++} ++ ++/** ++ * Where Steam keeps a game's Proton data. That can be another library than the game's, ++ * for example after moving the game to an SD card. With several, the one used last counts, ++ * and a game without any gets the folder next to it. ++ */ ++export function findCompatDataPath( ++ appId: string, ++ steamAppsPath: string, ++ libraries: Iterable, ++): string { ++ const beside = path.join(steamAppsPath, "compatdata", appId); ++ const elsewhere = [...libraries].map((library) => ++ path.join(library, "steamapps", "compatdata", appId), ++ ); ++ return ( ++ [...new Set([beside, ...elsewhere])] ++ .filter((candidate) => fs.existsSync(path.join(candidate, "pfx"))) ++ .sort((lhs, rhs) => prefixLastUsed(rhs) - prefixLastUsed(lhs))[0] ?? beside ++ ); ++} ++ + export interface SteamInstallation { + appId: string; + gamePath: string; +@@ -83,7 +113,7 @@ export function findSteamInstallation(gamePath: string): SteamInstallation | und + appId, + gamePath: installed, + steamAppsPath, +- prefix: path.join(steamAppsPath, "compatdata", appId, "pfx"), ++ prefix: path.join(findCompatDataPath(appId, steamAppsPath, libraries), "pfx"), + }; + } catch { + /* Ignore incomplete manifests. */