From 68c6cc5829fe30ec9a51bf8d8581c8d5ea3a28c7 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 9 Nov 2026 20:08:53 +0100 Subject: [PATCH 1/4] Let a game added by hand choose its Wine prefix The prefix can be picked under Game settings, Wine, instead of through an environment variable in the Tools page's Edit dialog. The error message pointed at a Dashboard button that the default layout doesn't have. --- .SRCINFO | 4 +- CHANGELOG.md | 6 + PKGBUILD | 4 +- README.md | 4 +- patches/0038-linux-default-wine-prefix.patch | 6 +- patches/0042-linux-wine-prefix-setting.patch | 307 +++++++++++++++++++ 6 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 patches/0042-linux-wine-prefix-setting.patch diff --git a/.SRCINFO b/.SRCINFO index 6063131..3ce9ec9 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,7 @@ 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 = vortex.sh source = vortex.desktop source = README.md @@ -154,6 +155,7 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/CHANGELOG.md b/CHANGELOG.md index c68653b..76e611d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- A game added by hand can use another Wine prefix without touching + environment variables: open the game and pick the folder under Game settings, + Wine. Vortex checks that the folder is a prefix before using it. The error + message used to point at a button on the Dashboard that the current layout + doesn't have. (#22) + ## 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..eb9b315 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,7 @@ _patches=( '0039-linux-lutris-store.patch' '0040-linux-bottles-store.patch' '0041-linux-plugin-folder-prefix.patch' + '0042-linux-wine-prefix-setting.patch' ) source=( @@ -219,6 +220,7 @@ sha512sums=( '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..fdf01f3 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ selection takes precedence over its default selection. Heroic’s game settings select Wine, Proton or UMU; Flatpak tools run inside the Heroic Flatpak. 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. +For another prefix, open the game in Vortex and choose it under +`Game settings → Wine`. `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. diff --git a/patches/0038-linux-default-wine-prefix.patch b/patches/0038-linux-default-wine-prefix.patch index 5a53498..b9a3e01 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 for a game added by hand.", ), { 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 for a game added by hand, 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..9db5fea --- /dev/null +++ b/patches/0042-linux-wine-prefix-setting.patch @@ -0,0 +1,307 @@ +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 { canChooseWinePrefix } 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, ++ () => canChooseWinePrefix(), ++ 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,96 @@ ++import { mdiFolderOpen } from "@mdi/js"; ++import React, { useCallback, useContext, useEffect, useState } from "react"; ++import { useTranslation } from "react-i18next"; ++import { useDispatch, useSelector } from "react-redux"; ++ ++import { MainContext } from "@/contexts"; ++import { Button } from "@/ui/components/button/Button"; ++import { TextField } from "@/ui/components/form/text_field/TextField"; ++ ++import { ++ isWinePrefix, ++ resolvePrefixInput, ++ winePrefixParameters, ++} from "../../../util/linux/winePrefixSetting"; ++import { activeGameId } from "../../../util/selectors"; ++import { setGameParameters } from "../actions/settings"; ++import { currentGame, currentGameDiscovery } from "../selectors"; ++ ++/** Lets a game added by hand run in a prefix other than Wine's default. */ ++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 saved = discovery?.environment?.WINEPREFIX ?? ""; ++ const [draft, setDraft] = useState(saved); ++ const [error, setError] = useState(undefined); ++ ++ 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 prefix that isn't one would fail every file operation inside it, so it is never stored. ++ if (!clearing && (prefix === undefined || !isWinePrefix(prefix))) { ++ setError(t("This folder is not a Wine prefix: it has no drive_c folder.")); ++ return; ++ } ++ setError(undefined); ++ if ((prefix ?? "") !== saved) { ++ dispatch( ++ setGameParameters(gameId, winePrefixParameters(discovery, game?.environment, prefix)), ++ ); ++ } ++ }, ++ [dispatch, discovery, game, gameId, saved, t], ++ ); ++ ++ const browse = useCallback(() => { ++ void api.selectDir({ defaultPath: saved || undefined }).then((selected) => { ++ if (selected !== undefined) { ++ apply(selected); ++ } ++ }); ++ }, [api, apply, saved]); ++ ++ return ( ++
{ ++ event.preventDefault(); ++ apply(draft); ++ }} ++ > ++ 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,16 @@ function resolveGamePrefix(game: CompatibilityGame): string | null | undefined { + return undefined; + } + ++/** A game added by hand runs in a prefix of the user's choosing; a launcher owns any other. */ ++export function canChooseWinePrefix(game = gameProvider?.()): boolean { ++ return ( ++ process.platform === "linux" && ++ game !== undefined && ++ !game.store && ++ selectedGamePrefix(game) !== null ++ ); ++} ++ + /** + * 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,113 @@ ++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 { ++ canChooseWinePrefix, ++ registerCompatibilityStore, ++ resetCompatibilityPathCache, ++ selectedGamePrefix, ++} from "../compatibilityPaths"; ++import { isWinePrefix, resolvePrefixInput, winePrefixParameters } 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(isWinePrefix(root)).toBe(false); ++ fs.mkdirSync(path.join(root, "drive_c")); ++ expect(isWinePrefix(root)).toBe(true); ++ }); ++ ++ 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 only for a Windows game no launcher owns", () => { ++ registerCompatibilityStore("test-launcher", () => undefined); ++ const game = windowsGame(); ++ expect(canChooseWinePrefix(game)).toBe(true); ++ expect(canChooseWinePrefix({ ...game, store: "steam" })).toBe(false); ++ expect(canChooseWinePrefix({ ...game, store: "test-launcher" })).toBe(false); ++ expect(canChooseWinePrefix(undefined)).toBe(false); ++ ++ const native = path.join(root, "Games/Native"); ++ fs.mkdirSync(native); ++ fs.writeFileSync(path.join(native, "run.sh"), "#!/bin/sh\n"); ++ expect(canChooseWinePrefix({ path: native, executable: "run.sh" })).toBe(false); ++ }); ++ ++ it("keeps the setting reachable after a prefix was chosen", () => { ++ const game = windowsGame(); ++ expect(canChooseWinePrefix({ ...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,37 @@ ++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 function isWinePrefix(folder: string): boolean { ++ return fs.existsSync(path.join(folder, "drive_c")); ++} ++ ++/** ++ * What to store for a game to use `prefix`, or Wine's default without one. 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) }; ++} From 1209ded5c8f3fe0f540194eaac684f7739bde113 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 9 Nov 2026 20:37:28 +0100 Subject: [PATCH 2/4] Find a game's Proton prefix in any Steam library Steam can keep the prefix in another library than the game, as after moving it to an SD card. Vortex only looked next to the game, so game data and launching both failed. --- .SRCINFO | 2 + CHANGELOG.md | 4 + PKGBUILD | 2 + .../0043-linux-steam-prefix-any-library.patch | 217 ++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 patches/0043-linux-steam-prefix-any-library.patch diff --git a/.SRCINFO b/.SRCINFO index 3ce9ec9..59389f0 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -99,6 +99,7 @@ pkgbase = vortex-linux 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 @@ -156,6 +157,7 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e611d..45ef97b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ publishes a new Vortex version, taking any pending notes with it. Wine. Vortex checks that the folder is a prefix before using it. 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 diff --git a/PKGBUILD b/PKGBUILD index eb9b315..e0a2b86 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -143,6 +143,7 @@ _patches=( '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=( @@ -221,6 +222,7 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' 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..2e9f214 --- /dev/null +++ b/patches/0043-linux-steam-prefix-any-library.patch @@ -0,0 +1,217 @@ +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,50 @@ 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("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 +@@ -57,6 +57,10 @@ export async function resolveGameTool( + 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); ++ 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", +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; +@@ -110,13 +110,14 @@ export async function getProtonInfo( + steamAppsPath: string, + appId: string, + ): Promise { +- if (!(await detectProtonUsage(steamAppsPath, appId))) return { usesProton: false }; ++ const compatDataPath = 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. */ From b0be4b578803e1020d9112c8a1fbec7626e85429 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 9 Nov 2026 21:15:49 +0100 Subject: [PATCH 3/4] Show and change the Wine prefix of any game The Wine tab now covers launcher games too and shows which prefix is in use and where it came from. Launchers keep starting games in their own prefix, so the tab warns about that. --- CHANGELOG.md | 12 +- README.md | 6 +- patches/0038-linux-default-wine-prefix.patch | 4 +- patches/0042-linux-wine-prefix-setting.patch | 210 ++++++++++++++---- .../0043-linux-steam-prefix-any-library.patch | 67 +++++- 5 files changed, 243 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ef97b..897fb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased -- A game added by hand can use another Wine prefix without touching - environment variables: open the game and pick the folder under Game settings, - Wine. Vortex checks that the folder is a prefix before using it. The error - message used to point at a button on the Dashboard that the current layout - doesn't have. (#22) +- 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 diff --git a/README.md b/README.md index fdf01f3..856db3e 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,10 @@ selection takes precedence over its default selection. Heroic’s game settings select Wine, Proton or UMU; Flatpak tools run inside the Heroic Flatpak. A game added by hand runs in Wine's default prefix, `$WINEPREFIX` or `~/.wine`. -For another prefix, open the game in Vortex and choose it under -`Game settings → Wine`. +`Game settings → Wine` shows the prefix Vortex uses for a game and lets you +choose another one. 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. `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. diff --git a/patches/0038-linux-default-wine-prefix.patch b/patches/0038-linux-default-wine-prefix.patch index b9a3e01..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 choose the prefix in Game settings under Wine for a game added by hand.", ++ "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" }, ); @@ -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, choose the prefix in Game settings under Wine for a game added by hand, or set WINEPREFIX for a tool with Edit on the Tools page.", ++ "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 index 9db5fea..fa16c26 100644 --- a/patches/0042-linux-wine-prefix-setting.patch +++ b/patches/0042-linux-wine-prefix-setting.patch @@ -5,7 +5,7 @@ diff --git a/src/renderer/src/extensions/gamemode_management/index.ts b/src/rend import { showError } from "../../util/message"; import { resolveNativeExecutable, resolvePlatformPath } from "../../util/nativeExecutable"; import opn from "../../util/opn"; -+import { canChooseWinePrefix } from "../../util/compatibilityPaths"; ++import { usesWinePrefix } from "../../util/compatibilityPaths"; import { activeGameId, activeProfile } from "../../util/selectors"; import { getSafe } from "../../util/storeHelper"; import { batchDispatch } from "../../util/util"; @@ -17,7 +17,7 @@ diff --git a/src/renderer/src/extensions/gamemode_management/index.ts b/src/rend + "Wine", + LazyComponent(() => require("./views/WinePrefixSettings")), + undefined, -+ () => canChooseWinePrefix(), ++ () => usesWinePrefix(), + 80, + ); + @@ -28,26 +28,30 @@ diff --git a/src/renderer/src/extensions/gamemode_management/views/WinePrefixSet new file mode 100644 --- /dev/null +++ b/src/renderer/src/extensions/gamemode_management/views/WinePrefixSettings.tsx -@@ -0,0 +1,96 @@ +@@ -0,0 +1,167 @@ +import { mdiFolderOpen } from "@mdi/js"; -+import React, { useCallback, useContext, useEffect, useState } from "react"; ++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 { -+ isWinePrefix, ++ launcherName, + resolvePrefixInput, + winePrefixParameters, ++ winePrefixProblem, +} from "../../../util/linux/winePrefixSetting"; +import { activeGameId } from "../../../util/selectors"; +import { setGameParameters } from "../actions/settings"; +import { currentGame, currentGameDiscovery } from "../selectors"; + -+/** Lets a game added by hand run in a prefix other than Wine's default. */ ++/** 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); @@ -55,10 +59,20 @@ new file mode 100644 + 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); @@ -69,9 +83,18 @@ new file mode 100644 + setDraft(input); + const clearing = input.trim() === ""; + const prefix = clearing ? undefined : resolvePrefixInput(input); -+ // A prefix that isn't one would fail every file operation inside it, so it is never stored. -+ if (!clearing && (prefix === undefined || !isWinePrefix(prefix))) { -+ setError(t("This folder is not a Wine prefix: it has no drive_c folder.")); ++ // 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); @@ -81,31 +104,79 @@ new file mode 100644 + ); + } + }, -+ [dispatch, discovery, game, gameId, saved, t], ++ [dispatch, discovery, game, gameId, saved, store, t], + ); + + const browse = useCallback(() => { -+ void api.selectDir({ defaultPath: saved || undefined }).then((selected) => { ++ void api.selectDir({ defaultPath: saved || detected }).then((selected) => { + if (selected !== undefined) { + apply(selected); + } + }); -+ }, [api, apply, saved]); ++ }, [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)} @@ -118,7 +189,7 @@ new file mode 100644 + + {saved !== "" && ( + + )} + @@ -128,18 +199,19 @@ new file mode 100644 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,16 @@ function resolveGamePrefix(game: CompatibilityGame): string | null | undefined { +@@ -89,6 +89,17 @@ function resolveGamePrefix(game: CompatibilityGame): string | null | undefined { return undefined; } -+/** A game added by hand runs in a prefix of the user's choosing; a launcher owns any other. */ -+export function canChooseWinePrefix(game = gameProvider?.()): boolean { -+ return ( -+ process.platform === "linux" && -+ game !== undefined && -+ !game.store && -+ selectedGamePrefix(game) !== null -+ ); ++/** 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 }); +} + /** @@ -149,7 +221,7 @@ diff --git a/src/renderer/src/util/linux/winePrefixSetting.test.ts b/src/rendere new file mode 100644 --- /dev/null +++ b/src/renderer/src/util/linux/winePrefixSetting.test.ts -@@ -0,0 +1,113 @@ +@@ -0,0 +1,143 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; @@ -157,12 +229,18 @@ new file mode 100644 +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { -+ canChooseWinePrefix, ++ detectedGamePrefix, + registerCompatibilityStore, + resetCompatibilityPathCache, + selectedGamePrefix, ++ usesWinePrefix, +} from "../compatibilityPaths"; -+import { isWinePrefix, resolvePrefixInput, winePrefixParameters } from "./winePrefixSetting"; ++import { ++ launcherName, ++ resolvePrefixInput, ++ winePrefixParameters, ++ winePrefixProblem, ++} from "./winePrefixSetting"; + +describe.runIf(process.platform === "linux")("Wine prefix setting", () => { + let root: string; @@ -195,9 +273,33 @@ new file mode 100644 + }); + + it("recognises a prefix by its drive_c folder", () => { -+ expect(isWinePrefix(root)).toBe(false); ++ expect(winePrefixProblem(root, undefined)).toBe("not-a-prefix"); + fs.mkdirSync(path.join(root, "drive_c")); -+ expect(isWinePrefix(root)).toBe(true); ++ 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", () => { @@ -244,30 +346,30 @@ new file mode 100644 + expect(selectedGamePrefix({ ...game, environment })).toBe(prefix); + }); + -+ it("offers the setting only for a Windows game no launcher owns", () => { ++ it("offers the setting for every Windows game, launcher or not", () => { + registerCompatibilityStore("test-launcher", () => undefined); + const game = windowsGame(); -+ expect(canChooseWinePrefix(game)).toBe(true); -+ expect(canChooseWinePrefix({ ...game, store: "steam" })).toBe(false); -+ expect(canChooseWinePrefix({ ...game, store: "test-launcher" })).toBe(false); -+ expect(canChooseWinePrefix(undefined)).toBe(false); ++ 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(canChooseWinePrefix({ path: native, executable: "run.sh" })).toBe(false); ++ expect(usesWinePrefix({ path: native, executable: "run.sh" })).toBe(false); + }); + + it("keeps the setting reachable after a prefix was chosen", () => { + const game = windowsGame(); -+ expect(canChooseWinePrefix({ ...game, environment: { WINEPREFIX: "/gone" } })).toBe(true); ++ 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,37 @@ +@@ -0,0 +1,61 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; @@ -282,12 +384,36 @@ new file mode 100644 + return path.isAbsolute(expanded) ? path.resolve(expanded) : undefined; +} + -+export function isWinePrefix(folder: string): boolean { -+ return fs.existsSync(path.join(folder, "drive_c")); ++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 Wine's default without one. The game's ++ * 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. + */ diff --git a/patches/0043-linux-steam-prefix-any-library.patch b/patches/0043-linux-steam-prefix-any-library.patch index 2e9f214..270f308 100644 --- a/patches/0043-linux-steam-prefix-any-library.patch +++ b/patches/0043-linux-steam-prefix-any-library.patch @@ -72,7 +72,7 @@ diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer 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,50 @@ describe.runIf(process.platform === "linux")("game tool runner", () => { +@@ -197,4 +197,81 @@ describe.runIf(process.platform === "linux")("game tool runner", () => { ), ).rejects.toThrow(/differs/); }); @@ -108,6 +108,37 @@ diff --git a/src/renderer/src/util/linux/gameTools.test.ts b/src/renderer/src/ut + 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"); @@ -126,10 +157,24 @@ diff --git a/src/renderer/src/util/linux/gameTools.test.ts b/src/renderer/src/ut 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 -@@ -57,6 +57,10 @@ export async function resolveGameTool( +@@ -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); +- 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.", @@ -137,6 +182,15 @@ diff --git a/src/renderer/src/util/linux/gameTools.ts b/src/renderer/src/util/li 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 @@ -149,12 +203,15 @@ diff --git a/src/renderer/src/util/linux/proton.ts b/src/renderer/src/util/linux export interface IProtonInfo { usesProton: boolean; -@@ -110,13 +110,14 @@ export async function getProtonInfo( +@@ -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 = findCompatDataPath(appId, steamAppsPath, steamLibraries([steamPath])); ++ const compatDataPath = ++ chosenCompatDataPath ?? findCompatDataPath(appId, steamAppsPath, steamLibraries([steamPath])); + if (!fs.existsSync(getWinePrefixPath(compatDataPath))) return { usesProton: false }; const protonName = await getConfiguredProtonName(steamPath, appId); const protonPath = From c7b25abd4ff1f283cd52a30c5a981cb871fd7821 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 9 Nov 2026 21:48:32 +0100 Subject: [PATCH 4/4] Enhance README with latest release badge and improve clarity on installation instructions --- README.md | 196 ++++++++++++++++++++---------------------------------- 1 file changed, 73 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 856db3e..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,181 +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. +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. -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. +## Install -Every fix that lands upstream is one less patch to carry here, and the goal is for this repo to shrink. +Download the file for your distribution from the [latest release](https://github.com/Arkazzae/VortexLinux/releases/latest). [CHANGELOG.md](CHANGELOG.md) says what changed. -## Install +| 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) | -Grab the [latest release](https://github.com/Arkazzae/VortexLinux/releases/latest). What changed in each release is in [CHANGELOG.md](CHANGELOG.md). +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. -### Arch +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`. -```sh -sha256sum --check vortex-linux-*.pkg.tar.zst.sha256 -sudo pacman -U ./vortex-linux-*.pkg.tar.zst -``` +> [!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. -### Debian and Ubuntu +### AppImage -For Ubuntu 24.04 or newer and Debian 13 or newer: +Install Wine from your package manager first, then: ```sh -sha256sum --check vortex-linux_*_amd64.deb.sha256 -sudo apt install ./vortex-linux_*_amd64.deb +chmod +x Vortex-*.AppImage +./Vortex-*.AppImage ``` -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. +.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`). -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. +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. -### Fedora and openSUSE +> [!TIP] +> No FUSE 2 on your system? Start it with `./Vortex-*.AppImage --appimage-extract-and-run`. -For Fedora 43 or newer, openSUSE Leap 16 and Tumbleweed: +## Before you add your first game -```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 -``` +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 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. +> [!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. -### Everything else (AppImage) +### Which Wine prefix Vortex uses -Install Wine from your package manager first, then: +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. -```sh -sha256sum --check Vortex-*.AppImage.sha256 -chmod +x Vortex-*.AppImage -./Vortex-*.AppImage -``` +`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 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. +Vortex's own Windows helpers, and tools started with no game selected, use a separate prefix in `~/.local/share/vortex-linux/wineprefix`. -No FUSE 2 on your system? Run it as: +A few environment variables help when Vortex can't find something: -```sh -./Vortex-*.AppImage --appimage-extract-and-run -``` +| 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. | -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. +### Lutris and Bottles (experimental) -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. +Both are new and lightly tested, so please report what breaks. -## Before you add your first game +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. + +- **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. -**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. - -**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. - -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. - -A game added by hand runs in Wine's default prefix, `$WINEPREFIX` or `~/.wine`. -`Game settings → Wine` shows the prefix Vortex uses for a game and lets you -choose another one. 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. -`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. - -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.** 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`. - -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. - -The separate prefix for Vortex’s own helper processes still defaults to -`${XDG_DATA_HOME:-$HOME/.local/share}/vortex-linux/wineprefix`. - -## 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 @@ -191,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