From 4d9bb07cbccf4abf66f734490c25c350516896fd Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Thu, 22 Oct 2026 18:30:45 +0200 Subject: [PATCH 1/3] Split backslash archive paths despite the extension path patch --- CHANGELOG.md | 5 + .../0035-linux-backslash-archive-paths.patch | 276 +++++++++++++----- 2 files changed, 216 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9e6a6..7eb51f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- Mods whose archives use Windows backslashes between folders install + correctly. Vortex can also remove a leftover `.installing` folder, and mods + that earlier releases installed with backslashes in their file names, instead + of reporting them as "open in another application". (#16) + ## 2.9.0-1 - 2026-10-19 - Vortex 2.9.0. diff --git a/patches/0035-linux-backslash-archive-paths.patch b/patches/0035-linux-backslash-archive-paths.patch index 25ae101..ac9087b 100644 --- a/patches/0035-linux-backslash-archive-paths.patch +++ b/patches/0035-linux-backslash-archive-paths.patch @@ -23,13 +23,14 @@ diff --git a/src/renderer/src/extensions/mod_management/util/archivePaths.test.t new file mode 100644 --- /dev/null +++ b/src/renderer/src/extensions/mod_management/util/archivePaths.test.ts -@@ -0,0 +1,92 @@ +@@ -0,0 +1,120 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + ++import { withPatchedPath } from "../../../test-utils/patchedPath"; +import { splitBackslashNames } from "./archivePaths"; + +function tree(root: string): string[] { @@ -50,77 +51,104 @@ new file mode 100644 + return result.sort(); +} + -+describe.runIf(process.platform === "linux")("archive entries with backslash separators", () => { -+ let root: string; -+ beforeEach(() => { -+ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-archive-paths-")); -+ }); -+ afterEach(() => fs.rmSync(root, { recursive: true, force: true })); -+ -+ it("turns the names 7-Zip extracts from a Windows-style archive into folders", async () => { -+ // What 7-Zip on Linux extracts from the Appearance Runtime Plugin archive. -+ fs.mkdirSync(path.join(root, "red4ext\\plugins\\")); -+ fs.writeFileSync( -+ path.join(root, "red4ext\\plugins\\AppearanceRuntimePlugin\\AppearanceRuntimePlugin.dll"), -+ "dll", -+ ); -+ fs.mkdirSync(path.join(root, "normal")); -+ fs.writeFileSync(path.join(root, "normal", "readme.txt"), "readme"); -+ -+ await splitBackslashNames(root); -+ -+ expect(tree(root)).toEqual([ -+ "normal/", -+ "normal/readme.txt=readme", -+ "red4ext/", -+ "red4ext/plugins/", -+ "red4ext/plugins/AppearanceRuntimePlugin/", -+ "red4ext/plugins/AppearanceRuntimePlugin/AppearanceRuntimePlugin.dll=dll", -+ ]); -+ }); ++for (const patchedPath of [false, true]) { ++ const split = (root: string) => ++ patchedPath ? withPatchedPath(() => splitBackslashNames(root)) : splitBackslashNames(root); + -+ it("merges into folders that already exist, also below a renamed folder", async () => { -+ fs.mkdirSync(path.join(root, "Data", "Scripts"), { recursive: true }); -+ fs.writeFileSync(path.join(root, "Data", "Scripts", "kept.pex"), "kept"); -+ fs.mkdirSync(path.join(root, "Data\\Scripts")); -+ fs.writeFileSync(path.join(root, "Data\\Scripts", "added.pex"), "added"); -+ fs.writeFileSync(path.join(root, "Data\\Scripts", "Source\\added.psc"), "source"); -+ -+ await splitBackslashNames(root); -+ -+ expect(tree(root)).toEqual([ -+ "Data/", -+ "Data/Scripts/", -+ "Data/Scripts/Source/", -+ "Data/Scripts/Source/added.psc=source", -+ "Data/Scripts/added.pex=added", -+ "Data/Scripts/kept.pex=kept", -+ ]); -+ }); ++ describe.runIf(process.platform === "linux")( ++ `archive entries with backslash separators${patchedPath ? " (renderer path patch)" : ""}`, ++ () => { ++ let root: string; ++ beforeEach(() => { ++ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-archive-paths-")); ++ }); ++ afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + -+ it("keeps entries inside the extraction folder", async () => { -+ fs.writeFileSync(path.join(root, "..\\..\\escape.txt"), "x"); -+ fs.writeFileSync(path.join(root, "\\absolute.txt"), "y"); ++ it("turns the names 7-Zip extracts from a Windows-style archive into folders", async () => { ++ // What 7-Zip on Linux extracts from the Appearance Runtime Plugin archive. ++ fs.mkdirSync(path.join(root, "red4ext\\plugins\\")); ++ fs.writeFileSync( ++ path.join(root, "red4ext\\plugins\\AppearanceRuntimePlugin\\AppearanceRuntimePlugin.dll"), ++ "dll", ++ ); ++ fs.mkdirSync(path.join(root, "normal")); ++ fs.writeFileSync(path.join(root, "normal", "readme.txt"), "readme"); + -+ await splitBackslashNames(root); ++ await split(root); + -+ expect(tree(root)).toEqual(["absolute.txt=y", "escape.txt=x"]); -+ }); ++ expect(tree(root)).toEqual([ ++ "normal/", ++ "normal/readme.txt=readme", ++ "red4ext/", ++ "red4ext/plugins/", ++ "red4ext/plugins/AppearanceRuntimePlugin/", ++ "red4ext/plugins/AppearanceRuntimePlugin/AppearanceRuntimePlugin.dll=dll", ++ ]); ++ }); + -+ it("leaves archives without backslashes untouched", async () => { -+ fs.mkdirSync(path.join(root, "Data")); -+ fs.writeFileSync(path.join(root, "Data", "plugin.esp"), "esp"); ++ it("turns empty entries that end in a backslash into folders", async () => { ++ // 7-Zip extracts folder entries as empty files when the archive doesn't mark them as ++ // folders, e.g. when it says it was made on Unix. ++ fs.writeFileSync(path.join(root, "bin\\x64\\"), ""); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\"), ""); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\Mod.dll"), "dll"); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\Empty\\"), ""); + -+ await splitBackslashNames(root); ++ await split(root); + -+ expect(tree(root)).toEqual(["Data/", "Data/plugin.esp=esp"]); -+ }); -+}); ++ expect(tree(root)).toEqual([ ++ "bin/", ++ "bin/x64/", ++ "bin/x64/plugins/", ++ "bin/x64/plugins/Empty/", ++ "bin/x64/plugins/Mod.dll=dll", ++ ]); ++ }); ++ ++ it("merges into folders that already exist, also below a renamed folder", async () => { ++ fs.mkdirSync(path.join(root, "Data", "Scripts"), { recursive: true }); ++ fs.writeFileSync(path.join(root, "Data", "Scripts", "kept.pex"), "kept"); ++ fs.mkdirSync(path.join(root, "Data\\Scripts")); ++ fs.writeFileSync(path.join(root, "Data\\Scripts", "added.pex"), "added"); ++ fs.writeFileSync(path.join(root, "Data\\Scripts", "Source\\added.psc"), "source"); ++ ++ await split(root); ++ ++ expect(tree(root)).toEqual([ ++ "Data/", ++ "Data/Scripts/", ++ "Data/Scripts/Source/", ++ "Data/Scripts/Source/added.psc=source", ++ "Data/Scripts/added.pex=added", ++ "Data/Scripts/kept.pex=kept", ++ ]); ++ }); ++ ++ it("keeps entries inside the extraction folder", async () => { ++ fs.writeFileSync(path.join(root, "..\\..\\escape.txt"), "x"); ++ fs.writeFileSync(path.join(root, "\\absolute.txt"), "y"); ++ ++ await split(root); ++ ++ expect(tree(root)).toEqual(["absolute.txt=y", "escape.txt=x"]); ++ }); ++ ++ it("leaves archives without backslashes untouched", async () => { ++ fs.mkdirSync(path.join(root, "Data")); ++ fs.writeFileSync(path.join(root, "Data", "plugin.esp"), "esp"); ++ ++ await split(root); ++ ++ expect(tree(root)).toEqual(["Data/", "Data/plugin.esp=esp"]); ++ }); ++ }, ++ ); ++} diff --git a/src/renderer/src/extensions/mod_management/util/archivePaths.ts b/src/renderer/src/extensions/mod_management/util/archivePaths.ts new file mode 100644 --- /dev/null +++ b/src/renderer/src/extensions/mod_management/util/archivePaths.ts -@@ -0,0 +1,41 @@ +@@ -0,0 +1,55 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + @@ -133,7 +161,7 @@ new file mode 100644 +export async function splitBackslashNames(root: string): Promise { + const visit = async (directory: string): Promise => { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { -+ const source = path.join(directory, entry.name); ++ const source = child(directory, entry.name); + if (!entry.name.includes("\\")) { + if (entry.isDirectory()) await visit(source); + continue; @@ -142,7 +170,13 @@ new file mode 100644 + .split("\\") + .filter((part) => part.length > 0 && part !== "." && part !== ".."); + if (parts.length === 0) continue; -+ const target = path.join(directory, ...parts); ++ const target = parts.reduce(child, directory); ++ if (entry.isFile() && entry.name.endsWith("\\") && (await fs.stat(source)).size === 0) { ++ // A folder entry the archive doesn't mark as one, which 7-Zip extracts as an empty file. ++ await fs.mkdir(target, { recursive: true }); ++ await fs.unlink(source); ++ continue; ++ } + await move(source, target, entry.isDirectory()); + if (entry.isDirectory()) await visit(target); + } @@ -150,11 +184,19 @@ new file mode 100644 + await visit(root); +} + ++/** ++ * The renderer patches path.join to turn backslashes into slashes for extensions ++ * (util/pathCompatibility.ts), which would rewrite the very names this file moves. ++ */ ++function child(directory: string, name: string): string { ++ return `${directory}/${name}`; ++} ++ +async function move(source: string, target: string, isDirectory: boolean): Promise { + const existing = await fs.stat(target).catch(() => undefined); + if (isDirectory && existing?.isDirectory()) { -+ for (const child of await fs.readdir(source, { withFileTypes: true })) { -+ await move(path.join(source, child.name), path.join(target, child.name), child.isDirectory()); ++ for (const entry of await fs.readdir(source, { withFileTypes: true })) { ++ await move(child(source, entry.name), child(target, entry.name), entry.isDirectory()); + } + await fs.rmdir(source); + return; @@ -162,3 +204,107 @@ new file mode 100644 + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.rename(source, target); +} +diff --git a/src/renderer/src/test-utils/patchedPath.ts b/src/renderer/src/test-utils/patchedPath.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/test-utils/patchedPath.ts +@@ -0,0 +1,21 @@ ++import { createRequire, syncBuiltinESMExports } from "node:module"; ++import type * as path from "node:path"; ++ ++import { patchPathCompatibility } from "../util/pathCompatibility"; ++ ++/** ++ * Runs fn with path.join, normalize and resolve turning backslashes into slashes, the way the ++ * renderer patches them at startup (util/monkeyPatching.ts). ++ */ ++export async function withPatchedPath(fn: () => PromiseLike): Promise { ++ const hostPath: typeof path = createRequire(__filename)("node:path"); ++ const { join, normalize, resolve } = hostPath; ++ patchPathCompatibility(hostPath, "linux"); ++ syncBuiltinESMExports(); ++ try { ++ return await fn(); ++ } finally { ++ Object.assign(hostPath, { join, normalize, resolve }); ++ syncBuiltinESMExports(); ++ } ++} +diff --git a/src/renderer/src/util/fs.linuxCompatibility.test.ts b/src/renderer/src/util/fs.linuxCompatibility.test.ts +--- a/src/renderer/src/util/fs.linuxCompatibility.test.ts ++++ b/src/renderer/src/util/fs.linuxCompatibility.test.ts +@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + // @ts-expect-error -- Vitest supports virtual modules at runtime but omits this overload. + vi.mock("original-fs", async () => import("node:fs"), { virtual: true }); + ++import { withPatchedPath } from "../test-utils/patchedPath"; + import { resetCompatibilityPathCache } from "./compatibilityPaths"; +-import { copyAsync, readFileSync, statAsync, statSync, writeFileAsync } from "./fs"; ++import { copyAsync, readFileSync, removeAsync, statAsync, statSync, writeFileAsync } from "./fs"; + + describe.runIf(process.platform === "linux")("filesystem compatibility profile paths", () => { + let tempDir: string; +@@ -91,3 +92,23 @@ describe.runIf(process.platform === "linux")("filesystem compatibility profile p + process.env[key] = value; + } + }); ++ ++describe.runIf(process.platform === "linux")("removing names with backslashes", () => { ++ let tempDir: string; ++ beforeEach(() => { ++ tempDir = nodeFs.mkdtempSync(path.join(os.tmpdir(), "vortex-fs-remove-")); ++ }); ++ afterEach(() => nodeFs.rmSync(tempDir, { force: true, recursive: true })); ++ ++ it("removes a mod that was installed with Windows separators in its file names", async () => { ++ const modPath = path.join(tempDir, "Appearance Runtime Plugin"); ++ nodeFs.mkdirSync(path.join(modPath, "red4ext\\plugins\\"), { recursive: true }); ++ nodeFs.writeFileSync(path.join(modPath, "red4ext\\plugins\\Mod\\Mod.dll"), "dll"); ++ nodeFs.mkdirSync(path.join(modPath, "red4ext", "plugins"), { recursive: true }); ++ nodeFs.writeFileSync(path.join(modPath, "red4ext", "plugins", "Other.dll"), "dll"); ++ ++ await withPatchedPath(() => removeAsync(modPath, { showDialogCallback: () => false })); ++ ++ expect(nodeFs.existsSync(modPath)).toBe(false); ++ }); ++}); +diff --git a/src/renderer/src/util/fs.ts b/src/renderer/src/util/fs.ts +--- a/src/renderer/src/util/fs.ts ++++ b/src/renderer/src/util/fs.ts +@@ -933,6 +933,9 @@ function removeInt( + } + + function rimrafAsync(remPath: string): PromiseBB { ++ if (process.platform === "linux") { ++ return PromiseBB.resolve(removeTree(remPath)); ++ } + return new PromiseBB((resolve, reject) => { + // don't use the rimraf implementation of busy retries because it's f*cked: + // https://github.com/isaacs/rimraf/issues/187 +@@ -952,6 +955,26 @@ function rimrafAsync(remPath: string): PromiseBB { + }); + } + ++/** ++ * rimraf builds child paths with path.join, which the renderer patches to turn backslashes into ++ * slashes (pathCompatibility.ts). Names extracted from Windows archives, such as ++ * "red4ext\plugins\Mod.dll", then look already gone and their folder can't be removed. ++ */ ++async function removeTree(remPath: string): Promise { ++ const ignoreMissing = (err: unknown) => { ++ if (getErrorCode(err) !== "ENOENT") throw err; ++ }; ++ const stats = await fs.lstat(remPath).catch(ignoreMissing); ++ if (!stats) return; ++ if (stats.isDirectory()) { ++ const names = await fs.readdir(remPath); ++ await Promise.all(names.map((name) => removeTree(`${remPath}/${name}`))); ++ await fs.rmdir(remPath).catch(ignoreMissing); ++ } else { ++ await fs.unlink(remPath).catch(ignoreMissing); ++ } ++} ++ + /** @deprecated use node:fs directly */ + export function readlinkAsync(linkPath: string): PromiseBB { + linkPath = resolveCaseInsensitiveSync(linkPath); From 607798ccaeaad96c53cf10b3eccb2ddebf3c654f Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Thu, 22 Oct 2026 18:30:45 +0200 Subject: [PATCH 2/3] Deploy differently cased folders into one folder --- .SRCINFO | 2 + CHANGELOG.md | 3 + PKGBUILD | 2 + .../0037-linux-case-consistent-folders.patch | 114 ++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 patches/0037-linux-case-consistent-folders.patch diff --git a/.SRCINFO b/.SRCINFO index ec6841e..5c4de37 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -93,6 +93,7 @@ pkgbase = vortex-linux source = 0034-linux-case-only-platform-paths.patch source = 0035-linux-backslash-archive-paths.patch source = 0036-linux-appimage-nxm-links.patch + source = 0037-linux-case-consistent-folders.patch source = vortex.sh source = vortex.desktop source = README.md @@ -144,6 +145,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 7eb51f9..805bfd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ publishes a new Vortex version, taking any pending notes with it. correctly. Vortex can also remove a leftover `.installing` folder, and mods that earlier releases installed with backslashes in their file names, instead of reporting them as "open in another application". (#16) +- Folders that mods spell in different case, such as `textures` and + `Textures`, are deployed into one folder. Vortex created both when it linked + their files at the same time, and the game found only one of them. (#20) ## 2.9.0-1 - 2026-10-19 diff --git a/PKGBUILD b/PKGBUILD index cccea52..e0d64b0 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -137,6 +137,7 @@ _patches=( '0034-linux-case-only-platform-paths.patch' '0035-linux-backslash-archive-paths.patch' '0036-linux-appimage-nxm-links.patch' + '0037-linux-case-consistent-folders.patch' ) source=( @@ -209,6 +210,7 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' diff --git a/patches/0037-linux-case-consistent-folders.patch b/patches/0037-linux-case-consistent-folders.patch new file mode 100644 index 0000000..6a6a1ef --- /dev/null +++ b/patches/0037-linux-case-consistent-folders.patch @@ -0,0 +1,114 @@ +diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts +--- a/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts ++++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts +@@ -161,3 +161,65 @@ test.runIf(process.platform === "linux")( + ); + }, + ); ++ ++for (const method of ["hardlink", "symlink"] as const) { ++ test.runIf(process.platform === "linux")( ++ `${method} deployment puts folders that mods spell in different case into one folder`, ++ async ({ makeDeployment }) => { ++ // Fallout 4 has no loose Sound or Textures folders, so the mods create them while their ++ // files are linked in parallel. ++ const lowercase: Record = {}; ++ const capitalized: Record = {}; ++ for (let idx = 0; idx < 40; idx++) { ++ lowercase[`sound/fx/npc/creature${idx % 7}/a${idx}.wav`] = "a"; ++ lowercase[`textures/actors/character/hair${idx % 5}/a${idx}.dds`] = "a"; ++ capitalized[`Sound/FX/NPC/Creature${idx % 7}/b${idx}.wav`] = "b"; ++ capitalized[`Textures/Actors/Character/Hair${idx % 5}/b${idx}.dds`] = "b"; ++ } ++ const h = makeDeployment({ method, modName: "Lowercase", files: lowercase }); ++ for (const [relPath, content] of Object.entries(capitalized)) { ++ const source = path.join(h.stagingDir, "Capitalized", relPath); ++ nodeFs.mkdirSync(path.dirname(source), { recursive: true }); ++ nodeFs.writeFileSync(source, content); ++ } ++ const blacklist = new BlacklistSet([], { details: {} } as IGame, h.normalize); ++ ++ await h.method.prepare(h.gameDir, false, [], h.normalize); ++ for (const modName of ["Lowercase", "Capitalized"]) { ++ await h.method.activate(path.join(h.stagingDir, modName), modName, "", blacklist); ++ } ++ await h.method.finalize("skyrimse", h.gameDir, h.stagingDir); ++ ++ const deployed = nodeFs.readdirSync(h.gameDir, { recursive: true, withFileTypes: true }); ++ const relative = (entry: nodeFs.Dirent) => ++ path.relative(h.gameDir, path.join(entry.parentPath, entry.name)).toLowerCase(); ++ expect( ++ deployed ++ .filter((entry) => entry.isDirectory()) ++ .map(relative) ++ .sort(), ++ ).toEqual( ++ [ ++ "sound", ++ "sound/fx", ++ "sound/fx/npc", ++ ...[0, 1, 2, 3, 4, 5, 6].map((idx) => `sound/fx/npc/creature${idx}`), ++ "textures", ++ "textures/actors", ++ "textures/actors/character", ++ ...[0, 1, 2, 3, 4].map((idx) => `textures/actors/character/hair${idx}`), ++ ].sort(), ++ ); ++ expect( ++ deployed ++ .filter((entry) => !entry.isDirectory() && !entry.name.startsWith(".")) ++ .map(relative) ++ .sort(), ++ ).toEqual( ++ [...Object.keys(lowercase), ...Object.keys(capitalized)] ++ .map((relPath) => relPath.toLowerCase()) ++ .sort(), ++ ); ++ }, ++ ); ++} +diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts +--- a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts ++++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts +@@ -85,6 +85,7 @@ abstract class LinkingActivator implements IDeploymentMethod { + private mQueue: Promise = Promise.resolve(); + private mContext: IDeploymentContext; + private mDirCache: Set; ++ private mDirQueue: Promise = Promise.resolve(); + + constructor( + id: string, +@@ -663,16 +664,23 @@ abstract class LinkingActivator implements IDeploymentMethod { + } + }; + +- return Promise.resolve( +- this.mDirCache === undefined || !this.mDirCache.has(dirPath) +- ? fs.ensureDirAsync(dirPath, onDirCreated).then(() => { +- if (this.mDirCache === undefined) { +- this.mDirCache = new Set(); +- } +- this.mDirCache.add(dirPath); +- }) +- : Promise.resolve(), +- ).then(() => didCreate); ++ const key = this.mNormalize !== undefined ? this.mNormalize(dirPath) : dirPath; ++ if (this.mDirCache?.has(key)) { ++ return Promise.resolve(false); ++ } ++ // Create one directory at a time. ensureDirAsync reuses the casing of a directory that ++ // exists, but links deployed in parallel to new "textures" and "Textures" folders would ++ // each find nothing and create both, and a Windows game sees only one of them. ++ const created = this.mDirQueue ++ .then(() => fs.ensureDirAsync(dirPath, onDirCreated)) ++ .then(() => { ++ if (this.mDirCache === undefined) { ++ this.mDirCache = new Set(); ++ } ++ this.mDirCache.add(key); ++ }); ++ this.mDirQueue = created.catch(() => undefined); ++ return created.then(() => didCreate); + } + + private deduplicate(input: IFileChange[]): IFileChange[] { From fe04fda9aa1df7c4c14c658da96a6ce127555195 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Thu, 22 Oct 2026 23:59:59 +0200 Subject: [PATCH 3/3] Release 2.9.0-2 --- .SRCINFO | 2 +- PKGBUILD | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.SRCINFO b/.SRCINFO index 5c4de37..2038afe 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches pkgver = 2.9.0 - pkgrel = 1 + pkgrel = 2 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 diff --git a/PKGBUILD b/PKGBUILD index e0d64b0..01bf657 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -2,7 +2,7 @@ pkgname=vortex-linux pkgver=2.9.0 -pkgrel=1 +pkgrel=2 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux"