Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .SRCINFO
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
pkgbase = vortex-linux
pkgdesc = Community build of Vortex with generic Linux compatibility patches
pkgver = 2.9.0
pkgrel = 4
pkgrel = 5
url = https://github.com/Arkazzae/VortexLinux
install = vortex.install
arch = x86_64
Expand Down Expand Up @@ -97,6 +97,7 @@ pkgbase = vortex-linux
source = 0038-linux-default-wine-prefix.patch
source = 0039-linux-lutris-store.patch
source = 0040-linux-bottles-store.patch
source = 0041-linux-plugin-folder-prefix.patch
source = vortex.sh
source = vortex.desktop
source = README.md
Expand Down Expand Up @@ -152,6 +153,7 @@ pkgbase = vortex-linux
sha512sums = SKIP
sha512sums = SKIP
sha512sums = SKIP
sha512sums = SKIP
sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2
sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632
sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ publishes a new Vortex version, taking any pending notes with it.

## Unreleased

- Plugin management writes `plugins.txt` and `loadorder.txt` into the game's
Proton or Wine prefix again. Vortex 2.9.0 rewrote that code to use Node's
file system directly, which skipped the Linux folder mapping, so the files
ended up in `~/Local/<game>` and the game loaded no plugins. A `Plugins.txt`
the game left behind in a different letter case is renamed, so it can't
shadow Vortex's list on a case-sensitive disk. (#31)

## 2.9.0-4 - 2026-10-23

- Experimental: games installed through Lutris' Wine runner are recognised.
Expand Down
4 changes: 3 additions & 1 deletion PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

pkgname=vortex-linux
pkgver=2.9.0
pkgrel=4
pkgrel=5
pkgdesc="Community build of Vortex with generic Linux compatibility patches"
arch=('x86_64')
url="https://github.com/Arkazzae/VortexLinux"
Expand Down Expand Up @@ -141,6 +141,7 @@ _patches=(
'0038-linux-default-wine-prefix.patch'
'0039-linux-lutris-store.patch'
'0040-linux-bottles-store.patch'
'0041-linux-plugin-folder-prefix.patch'
)

source=(
Expand Down Expand Up @@ -217,6 +218,7 @@ sha512sums=(
'SKIP'
'SKIP'
'SKIP'
'SKIP'
# Keep integrity checks for binaries downloaded outside this repository.
'935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2'
'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632'
Expand Down
158 changes: 158 additions & 0 deletions patches/0041-linux-plugin-folder-prefix.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/util/gameSupport.linux.test.ts b/src/renderer/src/extensions/gamebryo_plugin_management/util/gameSupport.linux.test.ts
new file mode 100644
--- /dev/null
+++ b/src/renderer/src/extensions/gamebryo_plugin_management/util/gameSupport.linux.test.ts
@@ -0,0 +1,64 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ resetCompatibilityPathCache,
+ setCompatibilityGameProvider,
+} from "../../../util/compatibilityPaths";
+import { pluginPath } from "./gameSupport";
+
+// Keep the host's own Steam libraries out of the no-game lookup below.
+vi.mock("node:os", async (original) => ({ ...(await original<typeof os>()), homedir: vi.fn() }));
+
+describe.runIf(process.platform === "linux")("plugin folder on Linux", () => {
+ let root: string;
+ afterEach(() => {
+ setCompatibilityGameProvider(undefined);
+ resetCompatibilityPathCache();
+ vi.unstubAllEnvs();
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it("points plugin management at the selected game's Wine prefix", () => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-plugin-folder-"));
+ vi.mocked(os.homedir).mockReturnValue(root);
+ vi.stubEnv("XDG_DATA_HOME", path.join(root, ".local/share"));
+ vi.stubEnv("VORTEX_COMPAT_PREFIXES", "");
+ const prefix = path.join(root, "pfx");
+ const local = path.join(prefix, "drive_c/users/steamuser/AppData/Local");
+ fs.mkdirSync(local, { recursive: true });
+ // Vortex's main process exports the host folder as LOCALAPPDATA on Linux.
+ vi.stubEnv("LOCALAPPDATA", path.join(root, "Local"));
+ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", path.join(root, "Local"));
+ setCompatibilityGameProvider(() => ({
+ path: path.join(root, "game"),
+ environment: { WINEPREFIX: prefix },
+ }));
+
+ expect(pluginPath("skyrimse")).toBe(path.join(local, "Skyrim Special Edition"));
+
+ // The game's own "Plugins.txt" must not shadow the list Vortex writes.
+ const folder = path.join(local, "Skyrim Special Edition");
+ fs.mkdirSync(folder, { recursive: true });
+ fs.writeFileSync(path.join(folder, "Plugins.txt"), "# stub");
+ expect(pluginPath("skyrimse")).toBe(folder);
+ expect(fs.readdirSync(folder).sort()).toEqual(["plugins.txt"]);
+ expect(fs.readFileSync(path.join(folder, "plugins.txt"), "utf8")).toBe("# stub");
+ fs.writeFileSync(path.join(folder, "LoadOrder.txt"), "old");
+ fs.writeFileSync(path.join(folder, "loadorder.txt"), "vortex");
+ pluginPath("skyrimse");
+ expect(fs.readdirSync(folder).sort()).toEqual([
+ "LoadOrder.txt.vortex_backup",
+ "loadorder.txt",
+ "plugins.txt",
+ ]);
+ expect(fs.readFileSync(path.join(folder, "loadorder.txt"), "utf8")).toBe("vortex");
+
+ // Without a selected game there is nothing to map to.
+ setCompatibilityGameProvider(undefined);
+ expect(pluginPath("skyrimse")).toBe(path.join(root, "Local", "Skyrim Special Edition"));
+ });
+});
diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/util/gameSupport.ts b/src/renderer/src/extensions/gamebryo_plugin_management/util/gameSupport.ts
--- a/src/renderer/src/extensions/gamebryo_plugin_management/util/gameSupport.ts
+++ b/src/renderer/src/extensions/gamebryo_plugin_management/util/gameSupport.ts
@@ -1,3 +1,4 @@
+import * as nodeFs from "node:fs";
import * as path from "path";

import Bluebird from "bluebird";
@@ -8,6 +9,7 @@ import type { IExtensionApi } from "../../../types/IExtensionContext";
import type { IState } from "../../../types/IState";
import * as fs from "../../../util/fs";
import getVortexPath from "../../../util/getVortexPath";
+import { resolveCaseInsensitiveSync } from "../../../util/linuxCompatibility";
import { makeOverlayableDictionary } from "../../../util/util";
import { discoveryByGame, gameById } from "../../gamemode_management/selectors";
import type { IDiscoveryResult } from "../../gamemode_management/types/IDiscoveryResult";
@@ -381,6 +383,39 @@ export function appDataPath(gameMode: string): string {
: path.resolve(getVortexPath("appData"), "..", "Local", dataPath);
}

+/**
+ * The game creates "Plugins.txt" while Vortex writes "plugins.txt". Windows
+ * doesn't care, but on a case-sensitive disk Wine hands the game whichever name
+ * matches exactly, so a stale capitalised file would shadow Vortex's list. Keep
+ * one file per name, under the name Vortex uses.
+ */
+function unshadowPluginFiles(folder: string): void {
+ let entries: string[];
+ try {
+ entries = nodeFs.readdirSync(folder);
+ } catch {
+ return;
+ }
+ for (const name of ["plugins.txt", "loadorder.txt"]) {
+ const others = entries.filter((entry) => entry !== name && entry.toLowerCase() === name);
+ if (others.length === 0) continue;
+ const target = path.join(folder, name);
+ for (const other of others) {
+ const source = path.join(folder, other);
+ const moveTo = entries.includes(name) ? `${source}.vortex_backup` : target;
+ try {
+ nodeFs.renameSync(source, moveTo);
+ log("info", "renamed differently cased plugin list", { from: source, to: moveTo });
+ } catch (err) {
+ log("warn", "failed to rename differently cased plugin list", {
+ from: source,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ }
+}
+
export function gameDataPath(gameMode: string): string {
const customDataPath = gameSupport.get(gameMode, "gameDataPath");
if (customDataPath) {
@@ -394,10 +429,29 @@ export function pluginPath(gameMode: string): string {
// The path of the plugins.txt file, not the path to where the game
// stores its plugins.
const pluginsPath = gameSupport.get(gameMode, "pluginsPath");
- if (pluginsPath) {
- return pluginsPath;
+ const result = pluginsPath ? pluginsPath : appDataPath(gameMode);
+ return process.platform === "linux" ? prefixedPluginPath(result) : result;
+}
+
+/**
+ * Plugin management reads, writes and watches this folder with Node's own fs,
+ * which knows nothing about the game's Wine prefix. Hand it the folder the game
+ * actually reads; for native games the mapping leaves the path alone.
+ */
+function prefixedPluginPath(hostPath: string): string {
+ try {
+ const folder = resolveCaseInsensitiveSync(hostPath);
+ unshadowPluginFiles(folder);
+ return folder;
+ } catch (err) {
+ // No usable prefix yet, for example before the game's first launch. The
+ // callers report the missing files themselves.
+ log("debug", "plugin folder stays on the host", {
+ hostPath,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ return hostPath;
}
- return appDataPath(gameMode);
}

export function pluginFormat(gameMode: string): PluginFormat {
Loading