From 7f0219278964211e8c51def3c76b7e030c3ba3aa Mon Sep 17 00:00:00 2001 From: IDCs Date: Mon, 28 Sep 2026 10:27:21 +0100 Subject: [PATCH 01/10] Add a unit test harness to the Witcher 3 extension Wires up vitest with the extension test mocks, so the logic added alongside can be covered without a running Vortex. part of LAZ-1011 --- extensions/games/game-witcher3/package.json | 6 +++-- .../games/game-witcher3/vitest.config.ts | 24 +++++++++++++++++++ pnpm-lock.yaml | 3 +++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 extensions/games/game-witcher3/vitest.config.ts diff --git a/extensions/games/game-witcher3/package.json b/extensions/games/game-witcher3/package.json index 6346f08afc..10bf7d8c0e 100644 --- a/extensions/games/game-witcher3/package.json +++ b/extensions/games/game-witcher3/package.json @@ -4,7 +4,8 @@ "description": "Support for The Witcher 3", "scripts": { "_assets": "copyfiles -u 1 -f ./assets/* dist", - "build": "node build.mjs && pnpm run _assets && pnpm extractInfo" + "build": "node build.mjs && pnpm run _assets && pnpm extractInfo", + "test": "pnpm exec vitest run" }, "author": "Black Tree Gaming Ltd.", "license": "GPL-3.0", @@ -14,7 +15,8 @@ "game": "The Witcher 3" }, "devDependencies": { - "@nexusmods/vortex-api": "workspace:*" + "@nexusmods/vortex-api": "workspace:*", + "@vortex/extension-test-mocks": "workspace:*" }, "nx": { "tags": [ diff --git a/extensions/games/game-witcher3/vitest.config.ts b/extensions/games/game-witcher3/vitest.config.ts new file mode 100644 index 0000000000..c68b11d679 --- /dev/null +++ b/extensions/games/game-witcher3/vitest.config.ts @@ -0,0 +1,24 @@ +import { createRequire } from "node:module"; + +import { mergeConfig, defineConfig } from "vitest/config"; + +import baseConfig from "../../../vitest.base.config"; + +// Resolve through Node's package resolution so the alias survives any future +// move of the mock package. Regex `find` ensures `vortex-api` is the only +// specifier rewritten (vitest's default string alias is prefix-matched). +const require_ = createRequire(import.meta.url); +const VORTEX_API_MOCK = require_.resolve("@vortex/extension-test-mocks"); + +export default mergeConfig( + baseConfig, + defineConfig({ + resolve: { + alias: [{ find: /^@nexusmods\/vortex-api$/, replacement: VORTEX_API_MOCK }], + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9edcff9753..d967de3e38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2593,6 +2593,9 @@ importers: '@nexusmods/vortex-api': specifier: workspace:* version: link:../../../packages/vortex-api + '@vortex/extension-test-mocks': + specifier: workspace:* + version: link:../../../packages/extension-test-mocks extensions/games/game-wolcen: devDependencies: From 0f5cdadc830f1419b951ec164603682b77535231 Mon Sep 17 00:00:00 2001 From: IDCs Date: Mon, 28 Sep 2026 10:27:24 +0100 Subject: [PATCH 02/10] Support the Witcher 3 Remastered edition The remaster ships no bin/x64 and no graphicsdx11.xml, and adds in-game switches that silently stop deployed mods loading. - Detect the edition from the install, failing towards the older behaviour so nothing changes for Classic and Next-Gen. - Widen requiredFiles to a script present in every edition. The stored list is re-checked on startup, so the old value un-discovered the game for anyone who updated in place. - Repair the stored executable when it no longer exists, which is what the in-place update leaves behind. - Narrow the config matrix on the remaster, which has no graphicsdx11.xml to merge against. - Add health checks for the in-game mod switches and for mod folder names over the length the remaster accepts, and surface failures as notifications, since the health check page lists only mod requirements. - Preserve the mod switches when regenerating the menu mod settings file, which is otherwise rebuilt from a backup that predates them. - Show a one-time notice covering what the upgrade changes. - Gate the mod limit patcher, whose offsets do not apply to the remaster. - Query the registry under the key both executables actually use. part of LAZ-1011 --- extensions/games/game-witcher3/src/actions.ts | 2 + extensions/games/game-witcher3/src/common.ts | 32 ++- .../game-witcher3/src/contentManager.test.ts | 88 ++++++ .../games/game-witcher3/src/contentManager.ts | 49 ++++ .../games/game-witcher3/src/edition.test.ts | 102 +++++++ extensions/games/game-witcher3/src/edition.ts | 154 ++++++++++ .../games/game-witcher3/src/eventHandlers.ts | 100 ++++++- .../games/game-witcher3/src/healthChecks.ts | 268 ++++++++++++++++++ extensions/games/game-witcher3/src/index.ts | 105 +++++-- .../game-witcher3/src/installers.test.ts | 51 ++++ .../games/game-witcher3/src/installers.ts | 73 +++-- extensions/games/game-witcher3/src/mergers.ts | 9 +- .../games/game-witcher3/src/modFolders.ts | 46 +++ .../games/game-witcher3/src/modLimitPatch.ts | 6 + .../games/game-witcher3/src/reducers.ts | 6 +- extensions/games/game-witcher3/src/util.ts | 81 ++++-- 16 files changed, 1102 insertions(+), 70 deletions(-) create mode 100644 extensions/games/game-witcher3/src/contentManager.test.ts create mode 100644 extensions/games/game-witcher3/src/contentManager.ts create mode 100644 extensions/games/game-witcher3/src/edition.test.ts create mode 100644 extensions/games/game-witcher3/src/edition.ts create mode 100644 extensions/games/game-witcher3/src/healthChecks.ts create mode 100644 extensions/games/game-witcher3/src/installers.test.ts create mode 100644 extensions/games/game-witcher3/src/modFolders.ts diff --git a/extensions/games/game-witcher3/src/actions.ts b/extensions/games/game-witcher3/src/actions.ts index 16b9d44b5e..2c296cd97d 100644 --- a/extensions/games/game-witcher3/src/actions.ts +++ b/extensions/games/game-witcher3/src/actions.ts @@ -6,3 +6,5 @@ export const setSuppressModLimitPatch = createAction( "TW3_SET_SUPPRESS_LIMIT_PATCH", (suppress) => suppress, ); + +export const setRemasterNoticeSeen = createAction("TW3_SET_REMASTER_NOTICE_SEEN", (seen) => seen); diff --git a/extensions/games/game-witcher3/src/common.ts b/extensions/games/game-witcher3/src/common.ts index 5deef048e5..7dc5666593 100644 --- a/extensions/games/game-witcher3/src/common.ts +++ b/extensions/games/game-witcher3/src/common.ts @@ -110,10 +110,18 @@ export function getLoadOrderFilePath() { return path.join(util.getVortexPath("documents"), "The Witcher 3", LOAD_ORDER_FILENAME); } +export function getDx12UserSettingsPath() { + return path.join(util.getVortexPath("documents"), "The Witcher 3", DX12_USER_SETTINGS_FILENAME); +} + export function getPriorityTypeBranch() { return ["settings", "witcher3", "prioritytype"]; } +export function getRemasterNoticeSeenBranch() { + return ["settings", "witcher3", "remasterNoticeSeen"]; +} + export function getSuppressModLimitBranch() { return ["settings", "witcher3", "suppressModLimitPatch"]; } @@ -135,6 +143,7 @@ export const PART_SUFFIX = ".part.txt"; export const SCRIPT_MERGER_ID = "W3ScriptMerger"; export const MERGE_INV_MANIFEST = "MergeInventory.xml"; export const LOAD_ORDER_FILENAME = "mods.settings"; +export const DX12_USER_SETTINGS_FILENAME = "dx12user.settings"; export const I18N_NAMESPACE = "game-witcher3"; export const CONFIG_MATRIX_REL_PATH = path.join( "bin", @@ -143,21 +152,14 @@ export const CONFIG_MATRIX_REL_PATH = path.join( "user_config_matrix", "pc", ); -export const CONFIG_MATRIX_FILES = [ - "audio", - "display", - "gameplay", - "gamma", - "graphics", - "graphicsdx11", - "hdr", - "hidden", - "hud", - "input", - "localization", -]; - -export const W3_TEMP_DATA_DIR = path.join(util.getVortexPath("temp"), "W3TempData"); +// Defined alongside the per-edition lists so the two can't drift apart. +export { CONFIG_MATRIX_FILES } from "./edition"; + +// Resolved lazily: getVortexPath isn't available at module load in every +// context this file gets imported from. +export function getW3TempDataDir() { + return path.join(util.getVortexPath("temp"), "W3TempData"); +} export const UNI_PATCH = "mod0000____CompilationTrigger"; export const LOCKED_PREFIX = "mod0000_"; diff --git a/extensions/games/game-witcher3/src/contentManager.test.ts b/extensions/games/game-witcher3/src/contentManager.test.ts new file mode 100644 index 0000000000..e9ac5b4f2f --- /dev/null +++ b/extensions/games/game-witcher3/src/contentManager.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { + CONTENT_MANAGER_MODS_SECTION, + ContentManagerState, + evaluateContentManagerSettings, +} from "./contentManager"; +import { modFolderNameFromPath, modFolderNames } from "./modFolders"; + +const section = (values: Record) => ({ + [CONTENT_MANAGER_MODS_SECTION]: values, + Gameplay: { ModioEnabled: "true" }, +}); + +describe("evaluateContentManagerSettings", () => { + it("reports mods as loading when both switches are on", () => { + expect(evaluateContentManagerSettings(section({ Enabled: "true", EnabledLocal: "true" }))).toBe( + ContentManagerState.Ok, + ); + }); + + it("reports all mod loading off when the master switch is off", () => { + expect( + evaluateContentManagerSettings(section({ Enabled: "false", EnabledLocal: "true" })), + ).toBe(ContentManagerState.ModsDisabled); + }); + + it("reports local mod loading off when only that switch is off", () => { + expect( + evaluateContentManagerSettings(section({ Enabled: "true", EnabledLocal: "false" })), + ).toBe(ContentManagerState.LocalModsDisabled); + }); + + it("accepts the numeric form of the switches", () => { + // The file is hand-editable, so 0/1 shows up in the wild. + expect(evaluateContentManagerSettings(section({ Enabled: "1", EnabledLocal: "0" }))).toBe( + ContentManagerState.LocalModsDisabled, + ); + expect(evaluateContentManagerSettings(section({ Enabled: "0" }))).toBe( + ContentManagerState.ModsDisabled, + ); + }); + + it("reports unknown when the section is absent", () => { + // Most likely the game has never been run, which is not a fault. + expect(evaluateContentManagerSettings({ Gameplay: {} })).toBe(ContentManagerState.Unknown); + expect(evaluateContentManagerSettings(undefined)).toBe(ContentManagerState.Unknown); + }); + + it("treats a section with neither switch present as loading", () => { + expect(evaluateContentManagerSettings(section({ MaxNameLength: "64" }))).toBe( + ContentManagerState.Ok, + ); + }); +}); + +describe("modFolderNameFromPath", () => { + it("takes the folder in front of the content directory", () => { + expect(modFolderNameFromPath("modFoo\\content\\scripts\\x.ws")).toBe("modFoo"); + expect(modFolderNameFromPath("modFoo/content/scripts/x.ws")).toBe("modFoo"); + }); + + it("matches the content directory regardless of case", () => { + expect(modFolderNameFromPath("modFoo\\Content\\blob0.bundle")).toBe("modFoo"); + }); + + it("ignores paths with no content directory", () => { + expect(modFolderNameFromPath("modFoo\\scripts\\x.ws")).toBeUndefined(); + }); + + it("ignores a content directory at the root", () => { + // Nothing in front of it to name the mod after. + expect(modFolderNameFromPath("content\\scripts\\x.ws")).toBeUndefined(); + }); +}); + +describe("modFolderNames", () => { + it("collects each folder once", () => { + expect( + modFolderNames([ + "modFoo\\content\\a.ws", + "modFoo\\content\\b.ws", + "modBar\\content\\c.ws", + "readme.txt", + ]), + ).toEqual(["modFoo", "modBar"]); + }); +}); diff --git a/extensions/games/game-witcher3/src/contentManager.ts b/extensions/games/game-witcher3/src/contentManager.ts new file mode 100644 index 0000000000..d0795a7270 --- /dev/null +++ b/extensions/games/game-witcher3/src/contentManager.ts @@ -0,0 +1,49 @@ +/** + * The Remastered edition gained a `[ContentManager/Mods]` section in + * `dx12user.settings` holding master switches for mod loading. With either of + * them off the game ignores everything Vortex deploys, and nothing surfaces on + * the Vortex side, so it has to be checked explicitly. + * + * Kept free of Vortex API imports so it can be unit tested. + */ + +export const CONTENT_MANAGER_MODS_SECTION = "ContentManager/Mods"; + +export const ContentManagerState = { + /** Mods are loading normally. */ + Ok: "ok", + /** All mod loading is off. */ + ModsDisabled: "mods-disabled", + /** mod.io and Workshop mods still load, but nothing we deploy does. */ + LocalModsDisabled: "local-mods-disabled", + /** Section absent - the game most likely hasn't been run yet. */ + Unknown: "unknown", +} as const; +export type ContentManagerState = (typeof ContentManagerState)[keyof typeof ContentManagerState]; + +// The game writes these as `false`, but accept the numeric form too since the +// file is hand-editable and the ini parser hands values back as strings. +function isSwitchedOff(value: unknown): boolean { + if (value === undefined || value === null) { + return false; + } + const normalised = String(value).trim().toLowerCase(); + return normalised === "false" || normalised === "0"; +} + +// key: ini section name, then key name within that section +type IniData = Record>; + +export function evaluateContentManagerSettings(data: IniData | undefined): ContentManagerState { + const section = data?.[CONTENT_MANAGER_MODS_SECTION]; + if (section === undefined) { + return ContentManagerState.Unknown; + } + if (isSwitchedOff(section.Enabled)) { + return ContentManagerState.ModsDisabled; + } + if (isSwitchedOff(section.EnabledLocal)) { + return ContentManagerState.LocalModsDisabled; + } + return ContentManagerState.Ok; +} diff --git a/extensions/games/game-witcher3/src/edition.test.ts b/extensions/games/game-witcher3/src/edition.test.ts new file mode 100644 index 0000000000..e66adfb662 --- /dev/null +++ b/extensions/games/game-witcher3/src/edition.test.ts @@ -0,0 +1,102 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + CONFIG_MATRIX_FILES, + DX11_EXECUTABLE, + DX12_EXECUTABLE, + EDITION_CAPABILITIES, + MAX_MOD_NAME_LENGTH, + W3Edition, + detectEditionFrom, + determineExecutableFrom, +} from "./edition"; + +const DX11 = path.join("bin", "x64", "witcher3.exe"); +const DX12 = path.join("bin", "x64_dx12", "witcher3.exe"); +const FREECAM = path.join("bin", "config", "base", "freecamera.ini"); + +/** Build the `exists` predicate from a list of paths the install contains. */ +const install = (...present: string[]) => { + const set = new Set(present); + return (relPath: string) => set.has(relPath); +}; + +describe("detectEditionFrom", () => { + it("reports Classic installs as legacy", () => { + expect(detectEditionFrom(install(DX11))).toBe(W3Edition.Legacy); + }); + + it("reports Next-Gen installs as legacy", () => { + // 4.x ships both renderers. + expect(detectEditionFrom(install(DX11, DX12))).toBe(W3Edition.Legacy); + }); + + it("reports a DirectX 12 only install as remastered", () => { + expect(detectEditionFrom(install(DX12))).toBe(W3Edition.Remastered); + }); + + it("recognises a remaster from its packaging markers alone", () => { + expect(detectEditionFrom(install("dlc-tombstones"))).toBe(W3Edition.Remastered); + expect(detectEditionFrom(install(FREECAM))).toBe(W3Edition.Remastered); + }); + + it("treats the DirectX 11 binary as decisive when markers contradict it", () => { + // A storefront could ship remaster-shaped extras alongside a 4.x tree; the + // renderer is the engine-level signal, so it wins. + expect(detectEditionFrom(install(DX11, DX12, "dlc-tombstones"))).toBe(W3Edition.Legacy); + }); + + it("falls back to legacy when the install shape is unrecognised", () => { + // Every edition-specific code path is gated on Remastered, so an install we + // can't read has to behave exactly as the extension always has. + expect(detectEditionFrom(install())).toBe(W3Edition.Legacy); + }); +}); + +describe("EDITION_CAPABILITIES", () => { + it("covers every edition", () => { + for (const edition of Object.values(W3Edition)) { + expect(EDITION_CAPABILITIES[edition]).toBeDefined(); + } + }); + + it("leaves the legacy config matrix list untouched", () => { + // Narrowing this for 4.x would silently stop menu mods merging. + expect(EDITION_CAPABILITIES[W3Edition.Legacy].configMatrixFiles).toEqual(CONFIG_MATRIX_FILES); + }); + + it("drops only graphicsdx11 from the remastered config matrix list", () => { + const remastered = EDITION_CAPABILITIES[W3Edition.Remastered].configMatrixFiles; + expect(remastered).not.toContain("graphicsdx11"); + expect(remastered).toEqual(CONFIG_MATRIX_FILES.filter((file) => file !== "graphicsdx11")); + }); + + it("enforces the mod folder name limit only on the remaster", () => { + expect(EDITION_CAPABILITIES[W3Edition.Legacy].enforcesModNameLength).toBe(false); + expect(EDITION_CAPABILITIES[W3Edition.Remastered].enforcesModNameLength).toBe(true); + }); +}); + +describe("determineExecutableFrom", () => { + it("prefers the DirectX 12 binary when the install has one", () => { + expect(determineExecutableFrom(install(DX11_EXECUTABLE, DX12_EXECUTABLE))).toBe( + DX12_EXECUTABLE, + ); + expect(determineExecutableFrom(install(DX12_EXECUTABLE))).toBe(DX12_EXECUTABLE); + }); + + it("falls back to the DirectX 11 binary when nothing is found", () => { + // Vortex calls the game's executable() with no arguments to establish a + // default, and that default is what every pre-remaster install launches + // through - changing it would break the Play button for existing users. + expect(determineExecutableFrom(install())).toBe(DX11_EXECUTABLE); + }); +}); + +describe("MAX_MOD_NAME_LENGTH", () => { + it("leaves room for a terminator under the game's declared limit of 64", () => { + expect(MAX_MOD_NAME_LENGTH).toBe(63); + }); +}); diff --git a/extensions/games/game-witcher3/src/edition.ts b/extensions/games/game-witcher3/src/edition.ts new file mode 100644 index 0000000000..484cfe47dc --- /dev/null +++ b/extensions/games/game-witcher3/src/edition.ts @@ -0,0 +1,154 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +/** + * Which build of the game an install is. + * + * Classic (1.x) and Next-Gen (4.x) share a single value because nothing this + * extension does differs between them; the Remastered build (5.0) dropped + * DirectX 11, restructured the game tree and added an in-game mod manager. + * + * This module deliberately imports nothing from the Vortex API so the detection + * logic stays unit-testable. + */ +export const W3Edition = { + Legacy: "legacy", + Remastered: "remastered", +} as const; +export type W3Edition = (typeof W3Edition)[keyof typeof W3Edition]; + +const DX11_BINARY = path.join("bin", "x64", "witcher3.exe"); +const DX12_BINARY = path.join("bin", "x64_dx12", "witcher3.exe"); + +// Corroborating evidence only. `dlc-tombstones` is a storefront packaging +// artifact and `freecamera.ini` may move, so neither is decisive on its own. +const REMASTER_MARKERS = ["dlc-tombstones", path.join("bin", "config", "base", "freecamera.ini")]; + +/** Config matrix XMLs the menu-mod merger is allowed to touch, per edition. */ +export const CONFIG_MATRIX_FILES = [ + "audio", + "display", + "gameplay", + "gamma", + "graphics", + "graphicsdx11", + "hdr", + "hidden", + "hud", + "input", + "localization", +]; + +// The remaster ships no graphicsdx11.xml. Merging against a base file that +// isn't there surfaces a "verify your game files" dialog the user can't act on. +const REMASTERED_CONFIG_MATRIX_FILES = CONFIG_MATRIX_FILES.filter( + (file) => file !== "graphicsdx11", +); + +export interface IEditionCapabilities { + /** Whether the game rejects mod folder names over MAX_MOD_NAME_LENGTH. */ + enforcesModNameLength: boolean; + /** Whether [ContentManager/Mods] can switch off everything we deploy. */ + hasLocalModMasterSwitch: boolean; + configMatrixFiles: string[]; +} + +// key: W3Edition +export const EDITION_CAPABILITIES: Record = { + [W3Edition.Legacy]: { + enforcesModNameLength: false, + hasLocalModMasterSwitch: false, + configMatrixFiles: CONFIG_MATRIX_FILES, + }, + [W3Edition.Remastered]: { + enforcesModNameLength: true, + hasLocalModMasterSwitch: true, + configMatrixFiles: REMASTERED_CONFIG_MATRIX_FILES, + }, +}; + +/** + * `[ContentManager/Mods] MaxNameLength` is 64 in the shipped config. Treat that + * as a buffer size including the terminator until a 64 character folder name has + * actually been tested in game, so the usable length is one less. + */ +export const MAX_MOD_NAME_LENGTH = 63; + +/** + * Pure form of {@link detectEdition}, taking a path predicate so it can be + * tested without a filesystem. + * + * Remastered requires positive evidence: every edition-specific code path is + * gated on it, so anything ambiguous has to resolve to Legacy, which is the + * behaviour the extension has always had. + */ +export function detectEditionFrom(exists: (relPath: string) => boolean): W3Edition { + // DirectX 11 was dropped in the remaster, and that's an engine change rather + // than a packaging one, so its presence rules the remaster out on every store. + if (exists(DX11_BINARY)) { + return W3Edition.Legacy; + } + if (exists(DX12_BINARY) || REMASTER_MARKERS.some(exists)) { + return W3Edition.Remastered; + } + return W3Edition.Legacy; +} + +export const DX11_EXECUTABLE = "bin/x64/witcher3.exe"; +export const DX12_EXECUTABLE = "bin/x64_DX12/witcher3.exe"; + +/** + * Pure form of the executable probe. + * + * The DirectX 11 path stays the fallback because Vortex calls the game's + * `executable()` with no arguments to establish a default, and that default is + * what every install predating the remaster launches through. + */ +export function determineExecutableFrom(exists: (relPath: string) => boolean): string { + return exists(DX12_EXECUTABLE) ? DX12_EXECUTABLE : DX11_EXECUTABLE; +} + +// key: game path, normalised and lowercased +const editionCache = new Map(); + +const cacheKey = (gamePath: string) => path.normalize(gamePath).toLowerCase(); + +/** + * Memoised so that the merge filter, the nag gate and the health checks can't + * disagree with each other part way through an operation. Invalidated per game + * activation rather than on a timer. + */ +export function detectEdition(gamePath: string | undefined): W3Edition { + if (gamePath === undefined || gamePath === "") { + return W3Edition.Legacy; + } + + const key = cacheKey(gamePath); + const cached = editionCache.get(key); + if (cached !== undefined) { + return cached; + } + + const edition = detectEditionFrom((relPath) => { + try { + return existsSync(path.join(gamePath, relPath)); + } catch { + // An unreadable game directory must not look like a remaster. + return false; + } + }); + editionCache.set(key, edition); + return edition; +} + +export function invalidateEditionCache(gamePath?: string) { + if (gamePath === undefined || gamePath === "") { + editionCache.clear(); + } else { + editionCache.delete(cacheKey(gamePath)); + } +} + +export function getEditionCapabilities(gamePath: string | undefined): IEditionCapabilities { + return EDITION_CAPABILITIES[detectEdition(gamePath)]; +} diff --git a/extensions/games/game-witcher3/src/eventHandlers.ts b/extensions/games/game-witcher3/src/eventHandlers.ts index 0121d119a2..3d0a0f8b16 100644 --- a/extensions/games/game-witcher3/src/eventHandlers.ts +++ b/extensions/games/game-witcher3/src/eventHandlers.ts @@ -1,15 +1,17 @@ /* eslint-disable */ import { actions, types, selectors, util } from "@nexusmods/vortex-api"; -import { setPriorityType } from "./actions"; +import { setPriorityType, setRemasterNoticeSeen } from "./actions"; import { GAME_ID, getPriorityTypeBranch, + getRemasterNoticeSeenBranch, PART_SUFFIX, INPUT_XML_FILENAME, SCRIPT_MERGER_ID, I18N_NAMESPACE, } from "./common"; +import { detectEdition, W3Edition } from "./edition"; import IniStructure from "./iniParser"; import * as menuMod from "./menumod"; import { storeToProfile, restoreFromProfile } from "./mergeBackup"; @@ -21,10 +23,64 @@ import { forceRefresh, suppressEventHandlers, notifyMissingScriptMerger, + shouldNotifyMissingScriptMerger, } from "./util"; type Deployment = { [modType: string]: types.IDeployedFile[] }; +/** + * Shown once, the first time a Remastered install is managed. The upgrade + * invalidates merged scripts and changes what the game will load, none of + * which surfaces anywhere else until something has already gone wrong. + * + * Deliberately not tied to edition transitions: someone who keeps both + * editions installed would otherwise see this every time they switch. + */ +function notifyRemasterOnce(api: types.IExtensionApi) { + const state = api.getState(); + if (util.getSafe(state, getRemasterNoticeSeenBranch(), false)) { + return; + } + + const discovery = selectors.discoveryByGame(state, GAME_ID); + if (detectEdition(discovery?.path) !== W3Edition.Remastered) { + return; + } + + api.store.dispatch(setRemasterNoticeSeen(true)); + api.sendNotification({ + id: "witcher3-remaster-detected", + type: "info", + message: "The Witcher 3 Remastered detected - some mods need attention", + allowSuppress: true, + actions: [ + { + title: "More", + action: (dismiss) => { + dismiss(); + api.showDialog( + "info", + "The Witcher 3 Remastered", + { + bbcode: + "This edition changes how mods are loaded. A few things to be aware of:[br][/br][br][/br]" + + "• Merged scripts from a previous install are no longer valid. Run the Script " + + "Merger again, or remove the merged mod, before playing.[br][/br][br][/br]" + + "• Script mods built for earlier versions may fail to compile. Map, map pin, " + + "Gwent and loot interface mods are the most affected.[br][/br][br][/br]" + + "• The game can now switch off locally installed mods by itself. If nothing " + + "loads, check the mod settings in game or run Vortex's health checks.[br][/br][br][/br]" + + "• Mod folder names longer than 63 characters are ignored by the game.[br][/br][br][/br]" + + "• DirectX 11 has been removed, so DirectX 11 only tools will not run.", + }, + [{ label: "Close" }], + ); + }, + }, + ], + }); +} + export function onGameModeActivation(api: types.IExtensionApi) { return async (gameMode: string) => { if (gameMode !== GAME_ID) { @@ -37,6 +93,7 @@ export function onGameModeActivation(api: types.IExtensionApi) { const activeProf = selectors.activeProfile(state); const priorityType = util.getSafe(state, getPriorityTypeBranch(), "prefix-based"); api.store.dispatch(setPriorityType(priorityType)); + notifyRemasterOnce(api); if (lastProfId !== activeProf?.id) { try { await storeToProfile(api, lastProfId).then(() => restoreFromProfile(api, activeProf?.id)); @@ -218,10 +275,43 @@ function getScriptMergerTool(api) { return undefined; } -function runScriptMerger(api) { +/** + * The remaster ships its vanilla scripts as UTF-8 where every earlier release + * used UTF-16, and the merger was built against the old encoding, so merging + * can write corrupted scripts into the merged mod. + */ +async function confirmMergerOnRemaster(api: types.IExtensionApi): Promise { + const discovery = selectors.discoveryByGame(api.getState(), GAME_ID); + if (detectEdition(discovery?.path) !== W3Edition.Remastered) { + return true; + } + + const result = await api.showDialog( + "info", + "Script Merger and the Remastered edition", + { + text: + "The Remastered edition stores its scripts in a different text encoding to earlier " + + "releases, and mods that need merging were generally built against the old one. " + + "Merging them may produce corrupted scripts.\n\n" + + "Mods written for the Remastered edition merge themselves and do not need this tool.", + }, + [{ label: "Cancel" }, { label: "Run anyway", default: true }], + "w3-merger-remastered-warning", + ); + return result.action === "Run anyway"; +} + +async function runScriptMerger(api) { const tool = getScriptMergerTool(api); if (tool?.path === undefined) { - notifyMissingScriptMerger(api); + // The user asked for the merger explicitly, so say it's missing even on + // editions we otherwise stay quiet about. + notifyMissingScriptMerger(api, true); + return Promise.resolve(); + } + + if (!(await confirmMergerOnRemaster(api))) { return Promise.resolve(); } @@ -239,6 +329,10 @@ function queryScriptMerge(api: types.IExtensionApi, reason: string) { // Do not bug users while they're installing a collection. return; } + if (!shouldNotifyMissingScriptMerger(api)) { + // Nothing to merge until two mods ship whole-file scripts. + return; + } const scriptMergerTool = util.getSafe( state, ["settings", "gameMode", "discovered", GAME_ID, "tools", SCRIPT_MERGER_ID], diff --git a/extensions/games/game-witcher3/src/healthChecks.ts b/extensions/games/game-witcher3/src/healthChecks.ts new file mode 100644 index 0000000000..e17697b2c9 --- /dev/null +++ b/extensions/games/game-witcher3/src/healthChecks.ts @@ -0,0 +1,268 @@ +import { log, selectors, types } from "@nexusmods/vortex-api"; +import IniParser, { WinapiFormat } from "vortex-parse-ini"; + +import { GAME_ID, getDx12UserSettingsPath, VORTEX_BACKUP_TAG } from "./common"; +import { + CONTENT_MANAGER_MODS_SECTION, + ContentManagerState, + evaluateContentManagerSettings, +} from "./contentManager"; +import { detectEdition, MAX_MOD_NAME_LENGTH, W3Edition } from "./edition"; +import { modFolderNames } from "./modFolders"; + +const LOCAL_MODS_CHECK_ID = "witcher3-local-mods-enabled"; +const MOD_NAME_LENGTH_CHECK_ID = "witcher3-mod-name-length"; + +const TRIGGERS: types.HealthCheckTrigger[] = [ + types.HealthCheckTrigger.GameChanged, + types.HealthCheckTrigger.Startup, + types.HealthCheckTrigger.ModsChanged, + types.HealthCheckTrigger.Manual, +]; + +function makeResult( + checkId: string, + status: types.IHealthCheckResult["status"], + severity: types.HealthCheckSeverity, + message: string, + startedAt: number, + details?: string, +): types.IHealthCheckResult { + return { + checkId, + status, + severity, + message, + details, + executionTime: Date.now() - startedAt, + timestamp: new Date(), + }; +} + +const passed = (checkId: string, message: string, startedAt: number): types.IHealthCheckResult => + makeResult(checkId, "passed", types.HealthCheckSeverity.Info, message, startedAt); + +function isRemastered(api: types.IExtensionApi): boolean { + const discovery = selectors.discoveryByGame(api.getState(), GAME_ID); + return detectEdition(discovery?.path) === W3Edition.Remastered; +} + +const newParser = () => new IniParser(new WinapiFormat()); + +/** + * Turns the mod switches back on, in the live settings file and in Vortex's + * backup of it. Both matter: the menu mod regenerates the live file from the + * backup, so fixing only the live copy gets reverted on the next deployment. + */ +async function enableLocalMods(): Promise { + const parser = newParser(); + const livePath = getDx12UserSettingsPath(); + + for (const target of [livePath, livePath + VORTEX_BACKUP_TAG]) { + try { + const doc = await parser.read(target); + const section = doc.data[CONTENT_MANAGER_MODS_SECTION]; + if (section === undefined) { + continue; + } + section.Enabled = "true"; + section.EnabledLocal = "true"; + // WinapiFormat writes only the changed keys, so the rest of the file - + // including anything the game added that we don't know about - survives. + await parser.write(target, doc); + } catch (err) { + // The backup only exists once a menu mod has been deployed. + log("debug", "W3: could not update mod switches", { target, error: err.message }); + } + } +} + +/** + * With `[ContentManager/Mods] Enabled` or `EnabledLocal` off, the Remastered + * edition ignores everything deployed into Mods\ and reports nothing. + */ +export const localModsEnabledCheck: types.IHealthCheck = { + id: LOCAL_MODS_CHECK_ID, + gameId: GAME_ID, + name: "The Witcher 3 - local mods enabled in game", + description: "Checks that the Remastered edition is set to load locally installed mods.", + category: types.HealthCheckCategory.Game, + severity: types.HealthCheckSeverity.Critical, + triggers: TRIGGERS, + check: async (api: types.IExtensionApi) => { + const startedAt = Date.now(); + if (!isRemastered(api)) { + return passed(LOCAL_MODS_CHECK_ID, "Not applicable to this edition", startedAt); + } + + let state: ContentManagerState; + try { + const doc = await newParser().read(getDx12UserSettingsPath()); + state = evaluateContentManagerSettings(doc.data); + } catch { + return makeResult( + LOCAL_MODS_CHECK_ID, + "warning", + types.HealthCheckSeverity.Warning, + "Game settings not found", + startedAt, + "Run the game once so it can create its settings file, then run this check again.", + ); + } + + switch (state) { + case ContentManagerState.ModsDisabled: + return makeResult( + LOCAL_MODS_CHECK_ID, + "failed", + types.HealthCheckSeverity.Critical, + "Mods are switched off in game", + startedAt, + "The game's gameplay settings have mods disabled, so nothing Vortex deploys will load.", + ); + case ContentManagerState.LocalModsDisabled: + return makeResult( + LOCAL_MODS_CHECK_ID, + "failed", + types.HealthCheckSeverity.Critical, + "Locally installed mods are switched off in game", + startedAt, + "The game is set to ignore locally installed mods, so nothing Vortex deploys will " + + "load. mod.io and Steam Workshop mods are unaffected.", + ); + case ContentManagerState.Unknown: + return passed(LOCAL_MODS_CHECK_ID, "Mod switches not set yet", startedAt); + default: + return passed(LOCAL_MODS_CHECK_ID, "Local mods are enabled", startedAt); + } + }, + fix: () => enableLocalMods(), +}; + +/** + * The Remastered edition caps mod folder names, and an over-long one is simply + * skipped rather than reported. + */ +export const modNameLengthCheck: types.IModHealthCheck = { + id: MOD_NAME_LENGTH_CHECK_ID, + gameId: GAME_ID, + name: "The Witcher 3 - mod folder name length", + description: "Checks that mod folder names are short enough for the Remastered edition.", + category: types.HealthCheckCategory.Mods, + severity: types.HealthCheckSeverity.Warning, + triggers: [types.HealthCheckTrigger.ModsChanged, types.HealthCheckTrigger.Manual], + checkMod: async (api: types.IExtensionApi, mod: types.IModCheckContext) => { + const startedAt = Date.now(); + // gameId is the only gate the runner offers, so other editions still pay + // for the per-mod walk and the result is discarded here. + if (!isRemastered(api)) { + return passed(MOD_NAME_LENGTH_CHECK_ID, "Not applicable to this edition", startedAt); + } + + const tooLong = modFolderNames(mod.files ?? []).filter( + (name) => name.length > MAX_MOD_NAME_LENGTH, + ); + if (tooLong.length > 0) { + return makeResult( + MOD_NAME_LENGTH_CHECK_ID, + "warning", + types.HealthCheckSeverity.Warning, + // Named in the message: the aggregate result is built from messages + // and drops the per-mod details. + `Mod folder name too long to load: ${tooLong.join(", ")}`, + startedAt, + `The game ignores mod folders longer than ${MAX_MOD_NAME_LENGTH} characters. ` + + `Rename these and redeploy: ${tooLong.join(", ")}`, + ); + } + return passed(MOD_NAME_LENGTH_CHECK_ID, "Mod folder names are within the limit", startedAt); + }, +}; + +export const healthChecks: Array = [ + localModsEnabledCheck, + modNameLengthCheck, +]; + +const notificationId = (checkId: string) => `w3-health-${checkId}`; + +function showResultDetails( + api: types.IExtensionApi, + check: types.IHealthCheck | types.IModHealthCheck, + result: types.IHealthCheckResult, +): void { + api.showDialog("info", check.name, { text: result.details ?? result.message }, [ + { label: "Close" }, + ]); +} + +async function applyFix( + api: types.IExtensionApi, + fix: types.HealthCheckFixFunction, + dismiss: () => void, +): Promise { + try { + await fix(api); + dismiss(); + api.sendNotification({ + type: "success", + message: "Setting applied. Restart the game for it to take effect.", + displayMS: 5000, + }); + } catch (err) { + api.showErrorNotification("Failed to change the game setting", err); + } +} + +function notifyResult( + api: types.IExtensionApi, + check: types.IHealthCheck | types.IModHealthCheck, + result: types.IHealthCheckResult | undefined, +): void { + const id = notificationId(check.id); + const failing = result?.status === "failed" || result?.status === "warning"; + if (!failing || selectors.activeGameId(api.getState()) !== GAME_ID) { + api.dismissNotification(id); + return; + } + + const actions: types.INotificationAction[] = [ + { title: "More", action: () => showResultDetails(api, check, result) }, + ]; + const fix = types.isModHealthCheck(check) ? undefined : check.fix; + if (fix !== undefined) { + actions.unshift({ title: "Fix", action: (dismiss) => void applyFix(api, fix, dismiss) }); + } + + api.sendNotification({ + id, + type: result.status === "failed" ? "error" : "warning", + message: result.message, + // Dismissable, but not permanently: the next run raises it again while the + // condition holds. + allowSuppress: false, + actions, + }); +} + +/** + * The health check page only lists mod requirements, so a failing check on this + * game reaches nobody. Mirror the results into notifications instead. + */ +export function registerHealthCheckNotifications(api: types.IExtensionApi): void { + for (const check of healthChecks) { + api.onStateChange( + ["session", "healthCheck", "results", check.id], + (_previous: types.IHealthCheckResult, current: types.IHealthCheckResult) => + notifyResult(api, check, current), + ); + } + + // Results persist for the session, so without this the notification outlives + // a switch to another game. + api.events.on("gamemode-activated", (gameMode: string) => { + if (gameMode !== GAME_ID) { + healthChecks.forEach((check) => api.dismissNotification(notificationId(check.id))); + } + }); +} diff --git a/extensions/games/game-witcher3/src/index.ts b/extensions/games/game-witcher3/src/index.ts index db5a866104..7332189289 100644 --- a/extensions/games/game-witcher3/src/index.ts +++ b/extensions/games/game-witcher3/src/index.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import path from "path"; import { actions, fs, log, selectors, types, util } from "@nexusmods/vortex-api"; @@ -14,6 +15,7 @@ import { LOCKED_PREFIX, SCRIPT_MERGER_ID, } from "./common"; +import { detectEdition, invalidateEditionCache } from "./edition"; import { onDidDeploy, onDidPurge, @@ -24,6 +26,7 @@ import { onSettingsChange, onWillDeploy, } from "./eventHandlers"; +import { healthChecks, registerHealthCheckNotifications } from "./healthChecks"; import { registerActions } from "./iconbarActions"; import IniStructure from "./iniParser"; import { @@ -46,7 +49,12 @@ import { getPersistentLoadOrder, migrate148 } from "./migrations"; import { testDLC, testTL } from "./modTypes"; import { PriorityManager } from "./priorityManager"; import { W3Reducer } from "./reducers"; -import { downloadScriptMerger, getScriptMergerDir, setMergerConfig } from "./scriptmerger"; +import { + downloadScriptMerger, + getScriptMergerDir, + repairStaleScriptMerger, + setMergerConfig, +} from "./scriptmerger"; import { getDLCPath, getAllMods, @@ -76,7 +84,11 @@ const tools: types.ITool[] = [ }, { id: GAME_ID + "_DX11", - name: "The Witcher 3 (DX11)", + // Vortex shows every declared tool whether its files resolve or not, so the + // remaster (which has no DirectX 11 binary) gets a permanently unconfigured + // tile. Naming it plainly is better than removing the only way 4.x and + // Classic users have of forcing DirectX 11. + name: "The Witcher 3 (DX11 - Classic/Next-Gen only)", logo: "auto", relative: true, executable: () => "bin/x64/witcher3.exe", @@ -92,13 +104,25 @@ const tools: types.ITool[] = [ }, ]; +// The first is what the game actually writes; the misspelling was here for +// years, so it's kept as a fallback in case anything ever populated it. +const REGISTRY_KEYS = [ + "Software\\CD Projekt RED\\The Witcher 3", + "Software\\CD Project Red\\The Witcher 3", +]; + function findGame(): Bluebird { try { - const instPath = winapi.RegGetValue( - "HKEY_LOCAL_MACHINE", - "Software\\CD Project Red\\The Witcher 3", - "InstallFolder", - ); + const instPath = REGISTRY_KEYS.reduce((found, key) => { + if (found) { + return found; + } + try { + return winapi.RegGetValue("HKEY_LOCAL_MACHINE", key, "InstallFolder"); + } catch (err) { + return undefined; + } + }, undefined); if (!instPath) { throw new Error("empty registry key"); } @@ -116,6 +140,28 @@ function findGame(): Bluebird { } } +/** + * An install that updated to the remaster in place still has the DirectX 11 + * binary recorded as its executable. That path is gone in 5.0, so the Play + * button fails with ENOENT until the stored value is re-resolved. + */ +function repairStaleExecutable(api: types.IExtensionApi, discovery: types.IDiscoveryResult) { + const stored = selectors.gameById(api.getState(), GAME_ID); + const current = discovery.executable ?? stored?.executable; + if (current === undefined || existsSync(path.join(discovery.path, current))) { + return; + } + + const resolved = determineExecutable(discovery.path); + if (resolved !== current) { + log("info", "witcher3 stored executable is missing, re-resolving", { + from: current, + to: resolved, + }); + api.store.dispatch(actions.setGameParameters(GAME_ID, { executable: resolved })); + } +} + function prepareForModding(api: types.IExtensionApi) { return (discovery: types.IDiscoveryResult) => { const findScriptMerger = async (error) => { @@ -135,14 +181,29 @@ function prepareForModding(api: types.IExtensionApi) { .ensureDirWritableAsync(dirpath) .catch((err) => (err.code === "EEXIST" ? Promise.resolve() : Promise.reject(err))); - return Promise.all([ - ensurePath(path.join(discovery.path, "Mods")), - ensurePath(path.join(discovery.path, "DLC")), - ensurePath(path.dirname(getLoadOrderFilePath())), - ]).then(() => - downloadScriptMerger(api).catch((err) => - err instanceof util.UserCanceled ? Promise.resolve() : findScriptMerger(err), - ), + // Re-probe once per activation so the merge filter, the script merger gate + // and the health checks all agree for the rest of this session. + invalidateEditionCache(discovery.path); + log("info", "witcher3 edition detected", { + edition: detectEdition(discovery.path), + path: discovery.path, + }); + repairStaleExecutable(api, discovery); + + return ( + Promise.all([ + ensurePath(path.join(discovery.path, "Mods")), + ensurePath(path.join(discovery.path, "DLC")), + ensurePath(path.dirname(getLoadOrderFilePath())), + ]) + // Runs before the download, which resolves the install directory from + // the tool this may re-point. + .then(() => repairStaleScriptMerger(api, discovery)) + .then(() => + downloadScriptMerger(api).catch((err) => + err instanceof util.UserCanceled ? Promise.resolve() : findScriptMerger(err), + ), + ) ); }; } @@ -164,7 +225,11 @@ function main(context: types.IExtensionContext) { setup: prepareForModding(context.api) as any, supportedTools: tools, requiresCleanup: true, - requiredFiles: ["bin/x64/witcher3.exe"], + // Must exist in every edition. Vortex re-checks these on startup and drops + // the discovery when one is missing, so the DirectX 11 binary can't be used + // here: the remaster ships without it and would un-discover the game for + // anyone who updates in place. + requiredFiles: ["content/content0/scripts/game/r4Game.ws"], environment: { SteamAPPId: "292030", }, @@ -295,6 +360,12 @@ function main(context: types.IExtensionContext) { getPriorityManager, }; context.registerLoadOrder(new TW3LoadOrder(props)); + + // Registered unconditionally; each check short-circuits on editions it + // doesn't apply to, since registrations can't vary per discovery. + for (const check of healthChecks) { + context.registerHealthCheck(check); + } // context.registerTest('tw3-mod-limit-breach', 'gamemode-activated', // () => Bluebird.resolve(testModLimitBreach(context.api, modLimitPatcher))); // context.registerTest('tw3-mod-limit-breach', 'mod-activated', @@ -318,6 +389,8 @@ function main(context: types.IExtensionContext) { ["settings", "witcher3"], onSettingsChange(context.api, getPriorityManager) as any, ); + + registerHealthCheckNotifications(context.api); }); return true; } diff --git a/extensions/games/game-witcher3/src/installers.test.ts b/extensions/games/game-witcher3/src/installers.test.ts new file mode 100644 index 0000000000..c01a2d1506 --- /dev/null +++ b/extensions/games/game-witcher3/src/installers.test.ts @@ -0,0 +1,51 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { GAME_ID } from "./common"; +import { MAX_MOD_NAME_LENGTH } from "./edition"; +import { installContent, testSupportedContent } from "./installers"; + +describe("testSupportedContent", () => { + it("claims archives that are a bare content directory", async () => { + const result = await testSupportedContent(["content\\scripts\\x.ws"], GAME_ID); + expect(result.supported).toBeTruthy(); + }); + + it("leaves archives with no top level content directory alone", async () => { + // These belong to the mod/dlc installers; claiming them here routes the + // files through the wrong destination logic. + const result = await testSupportedContent(["modFoo\\content\\scripts\\x.ws"], GAME_ID); + expect(result.supported).toBeFalsy(); + }); + + it("ignores archives for other games", async () => { + const result = await testSupportedContent(["content\\scripts\\x.ws"], "skyrim"); + expect(result.supported).toBeFalsy(); + }); +}); + +describe("installContent", () => { + const DEST = "C:\\staging\\Some Mod-1234.installing"; + + it("returns instructions in the shape the install manager expects", async () => { + const result = await installContent(["content\\scripts\\x.ws"], DEST); + expect(Array.isArray(result.instructions)).toBe(true); + }); + + it("names the mod folder after the archive rather than its staging path", async () => { + const result = await installContent(["content\\scripts\\x.ws"], DEST); + expect(result.instructions[0].destination).toBe( + path.join("modSomeMod-1234", "content", "scripts", "x.ws"), + ); + }); + + it("keeps the mod folder within the length the game accepts", async () => { + const result = await installContent( + ["content\\x.ws"], + `C:\\staging\\${"A".repeat(120)}.installing`, + ); + const folder = result.instructions[0].destination.split(/[\\/]/)[0]; + expect(folder.length).toBeLessThanOrEqual(MAX_MOD_NAME_LENGTH); + }); +}); diff --git a/extensions/games/game-witcher3/src/installers.ts b/extensions/games/game-witcher3/src/installers.ts index fd63b6c681..358b6805de 100644 --- a/extensions/games/game-witcher3/src/installers.ts +++ b/extensions/games/game-witcher3/src/installers.ts @@ -1,11 +1,48 @@ /* eslint-disable */ +import { readFile } from "node:fs/promises"; import path from "path"; import { types, util } from "@nexusmods/vortex-api"; import { CONFIG_MATRIX_REL_PATH, GAME_ID, SCRIPT_MERGER_FILES, PART_SUFFIX } from "./common"; +import { MAX_MOD_NAME_LENGTH } from "./edition"; +import { makeModFolderName } from "./modFolders"; +import { isAnnotationScript, isScript, SCRIPT_STYLE_ATTRIBUTE, W3ScriptStyle } from "./scriptStyle"; import { PrefixType } from "./types"; +/** Scripts ship in either encoding, so decode from the byte order mark. */ +const decodeScript = (buffer: Buffer): string => + buffer[0] === 0xff && buffer[1] === 0xfe ? buffer.toString("utf16le") : buffer.toString("utf8"); + +/** + * Records how the mod changes scripts, so later checks read an attribute rather + * than the file system. Stops at the first whole-file script. + */ +async function scriptStyleAttribute( + files: string[], + destinationPath: string, +): Promise { + const scripts = files.filter(isScript); + if (scripts.length === 0 || destinationPath === undefined) { + return [{ type: "attribute", key: SCRIPT_STYLE_ATTRIBUTE, value: W3ScriptStyle.None }]; + } + + let style: W3ScriptStyle = W3ScriptStyle.Annotation; + for (const script of scripts) { + try { + if (!isAnnotationScript(decodeScript(await readFile(path.join(destinationPath, script))))) { + style = W3ScriptStyle.Override; + break; + } + } catch { + // Unreadable during install; assume the cautious answer. + style = W3ScriptStyle.Override; + break; + } + } + return [{ type: "attribute", key: SCRIPT_STYLE_ATTRIBUTE, value: style }]; +} + export function scriptMergerTest(files, gameId) { const matcher = (file) => SCRIPT_MERGER_FILES.includes(file); const supported = gameId === GAME_ID && files.filter(matcher).length > 0; @@ -171,27 +208,30 @@ export function installMenuMod(files: string[], destinationPath: string) { export function testSupportedContent(files: string[], gameId: string) { const supported = - gameId === GAME_ID && - files.find((file) => file.toLowerCase().startsWith("content" + path.sep) !== undefined); + gameId === GAME_ID && files.some((file) => file.toLowerCase().startsWith("content" + path.sep)); return Promise.resolve({ supported, requiredFiles: [], }); } -export function installContent(files: string[], destinationPath: string) { - return Promise.resolve( - files - .filter((file) => file.toLowerCase().startsWith("content" + path.sep)) - .map((file) => { - const fileBase = file.split(path.sep).slice(1).join(path.sep); - return { - type: "copy", - source: file, - destination: path.join("mod" + destinationPath, fileBase), - }; - }), +export async function installContent(files: string[], destinationPath: string) { + const modName = makeModFolderName( + path.basename(destinationPath, ".installing"), + MAX_MOD_NAME_LENGTH, ); + const instructions: types.IInstruction[] = files + .filter((file) => file.toLowerCase().startsWith("content" + path.sep)) + .map((file) => { + const fileBase = file.split(path.sep).slice(1).join(path.sep); + return { + type: "copy", + source: file, + destination: path.join(modName, "content", fileBase), + }; + }); + instructions.push(...(await scriptStyleAttribute(files, destinationPath))); + return { instructions }; } export function testSupportedTL(files: string[], gameId: string) { @@ -316,7 +356,7 @@ export function testSupportedMixed( }); } -export function installMixed(files: string[]) { +export async function installMixed(files: string[], destinationPath: string) { // We can only assume that files with the 'dlc' prefix go inside dlc and files // with the 'mod' prefix go inside mods. const modNames: string[] = []; @@ -369,5 +409,6 @@ export function installMixed(files: string[]) { value: "witcher3menumodroot", }, ]); - return Promise.resolve({ instructions }); + instructions.push(...(await scriptStyleAttribute(files, destinationPath))); + return { instructions }; } diff --git a/extensions/games/game-witcher3/src/mergers.ts b/extensions/games/game-witcher3/src/mergers.ts index 68ee3e58a9..7ef29e3169 100644 --- a/extensions/games/game-witcher3/src/mergers.ts +++ b/extensions/games/game-witcher3/src/mergers.ts @@ -5,7 +5,8 @@ import { fs, types, selectors, util } from "@nexusmods/vortex-api"; import ini from "ini"; import { Builder, parseStringPromise } from "xml2js"; -import { GAME_ID, CONFIG_MATRIX_REL_PATH, CONFIG_MATRIX_FILES, VORTEX_BACKUP_TAG } from "./common"; +import { GAME_ID, CONFIG_MATRIX_REL_PATH, VORTEX_BACKUP_TAG } from "./common"; +import { getEditionCapabilities } from "./edition"; import { getPersistentLoadOrder } from "./migrations"; import { fileExists, getDocumentsPath, isSettingsFile, isXML } from "./util"; @@ -111,9 +112,13 @@ export const canMergeXML = (api: types.IExtensionApi) => { in: path.join(gameDiscovery.path, CONFIG_MATRIX_REL_PATH, file.relPath), out: path.join(CONFIG_MATRIX_REL_PATH, file.relPath), })), + // Narrowed per edition rather than switched off: the remaster still has + // ten of the eleven config matrix files, it just dropped graphicsdx11. filter: (filePath) => isXML(filePath) && - CONFIG_MATRIX_FILES.includes(path.basename(filePath, path.extname(filePath))), + getEditionCapabilities(gameDiscovery.path).configMatrixFiles.includes( + path.basename(filePath, path.extname(filePath)), + ), }; }; }; diff --git a/extensions/games/game-witcher3/src/modFolders.ts b/extensions/games/game-witcher3/src/modFolders.ts new file mode 100644 index 0000000000..fcc3d90863 --- /dev/null +++ b/extensions/games/game-witcher3/src/modFolders.ts @@ -0,0 +1,46 @@ +/** + * Mods are laid out as `/content/...`, and that folder name is what + * ends up as the section name in mods.settings and as the directory the game + * scans. Kept free of Vortex API imports so it can be unit tested. + */ + +/** Folder name a deployed file belongs to, or undefined if it isn't in one. */ +export function modFolderNameFromPath(filePath: string): string | undefined { + const segments = filePath.split(/[\\/]/); + const contentIdx = segments.findIndex((seg) => seg.toLowerCase() === "content"); + // A `content` segment at the root has no mod folder in front of it. + return [-1, 0].includes(contentIdx) ? undefined : segments[contentIdx - 1]; +} + +/** + * Builds the `mod`-prefixed folder name an archive installs into. + * + * Only over-long names are shortened, so names that already fit come out + * byte-identical to what earlier versions produced and existing deployments + * keep their folders. + */ +export function makeModFolderName(archiveName: string, maxLength: number): string { + const name = "mod" + archiveName.replace(/\s/g, ""); + if (name.length <= maxLength) { + return name; + } + // Keep a short suffix of the original so two long names can't collapse + // into the same folder. + let hash = 0; + for (let i = 0; i < name.length; i += 1) { + hash = (hash * 31 + name.charCodeAt(i)) >>> 0; + } + const suffix = "_" + hash.toString(36); + return name.slice(0, maxLength - suffix.length) + suffix; +} + +export function modFolderNames(filePaths: string[]): string[] { + const names = new Set(); + for (const filePath of filePaths) { + const name = modFolderNameFromPath(filePath); + if (name !== undefined) { + names.add(name); + } + } + return Array.from(names); +} diff --git a/extensions/games/game-witcher3/src/modLimitPatch.ts b/extensions/games/game-witcher3/src/modLimitPatch.ts index 09616199ad..097a5464db 100644 --- a/extensions/games/game-witcher3/src/modLimitPatch.ts +++ b/extensions/games/game-witcher3/src/modLimitPatch.ts @@ -6,6 +6,7 @@ import _ from "lodash"; import { setSuppressModLimitPatch } from "./actions"; import { GAME_ID, I18N_NAMESPACE } from "./common"; +import { detectEdition, W3Edition } from "./edition"; /** * Theoretically the mod limit patcher is no longer needed (CDPR raised the file handle limit) @@ -36,6 +37,11 @@ export class ModLimitPatcher { if (!discovery?.path) { throw new util.ProcessCanceled("Game is not discovered"); } + if (detectEdition(discovery.path) === W3Edition.Remastered) { + // The byte offsets below are specific to the 4.x executable, and the + // binary this patches doesn't exist in the remaster at all. + throw new util.ProcessCanceled("Mod limit patch does not apply to this edition"); + } await this.queryPatch(); const stagingPath = selectors.installPathForGame(state, GAME_ID); const modName = "Mod Limit Patcher"; diff --git a/extensions/games/game-witcher3/src/reducers.ts b/extensions/games/game-witcher3/src/reducers.ts index d2ca0c72b7..0ed2073911 100644 --- a/extensions/games/game-witcher3/src/reducers.ts +++ b/extensions/games/game-witcher3/src/reducers.ts @@ -1,6 +1,6 @@ import { types, util } from "@nexusmods/vortex-api"; -import { setPriorityType, setSuppressModLimitPatch } from "./actions"; +import { setPriorityType, setRemasterNoticeSeen, setSuppressModLimitPatch } from "./actions"; // reducer export const W3Reducer: types.IReducerSpec = { @@ -11,9 +11,13 @@ export const W3Reducer: types.IReducerSpec = { [setSuppressModLimitPatch as any]: (state, payload) => { return util.setSafe(state, ["suppressModLimitPatch"], payload); }, + [setRemasterNoticeSeen as any]: (state, payload) => { + return util.setSafe(state, ["remasterNoticeSeen"], payload); + }, }, defaults: { prioritytype: "prefix-based", suppressModLimitPatch: false, + remasterNoticeSeen: false, }, }; diff --git a/extensions/games/game-witcher3/src/util.ts b/extensions/games/game-witcher3/src/util.ts index 754974f992..5ece994ba5 100644 --- a/extensions/games/game-witcher3/src/util.ts +++ b/extensions/games/game-witcher3/src/util.ts @@ -12,8 +12,11 @@ import { ACTIVITY_ID_IMPORTING_LOADORDER, PART_SUFFIX, } from "./common"; +import { detectEdition, determineExecutableFrom, W3Edition } from "./edition"; import IniStructure from "./iniParser"; import { getMergedModNames } from "./mergeInventoryParsing"; +import { modFolderNameFromPath } from "./modFolders"; +import { SCRIPT_STYLE_ATTRIBUTE, shouldSuggestScriptMerger } from "./scriptStyle"; import { IDeployedFile, IDeployment, PrefixType } from "./types"; export async function getDeployment( @@ -87,7 +90,49 @@ export const isTW3 = (api: types.IExtensionApi) => { }; }; -export function notifyMissingScriptMerger(api) { +/** + * The merger has nothing to do until two mods claim the same script, so the + * suggestion waits for a second mod shipping whole-file scripts. Reads the style + * recorded at install rather than inspecting any mod's contents. + */ +export function shouldNotifyMissingScriptMerger(api: types.IExtensionApi): boolean { + const state = api.getState(); + const profile = selectors.activeProfile(state); + if (profile?.gameId !== GAME_ID) { + return false; + } + const mods: Record = state.persistent?.mods?.[GAME_ID] ?? {}; // key: mod id + const enabledStyles = Object.entries(profile.modState ?? {}) + .filter(([modId, modState]) => modState?.enabled && mods[modId] !== undefined) + .map(([modId]) => mods[modId].attributes?.[SCRIPT_STYLE_ATTRIBUTE]); + return shouldSuggestScriptMerger(enabledStyles); +} + +/** + * Extra guidance for the remaster, where the merger is still needed but the + * things that break are different. + */ +function remasteredMergerAdvice(api): string { + const discovery = selectors.discoveryByGame(api.getState(), GAME_ID); + if (detectEdition(discovery?.path) !== W3Edition.Remastered) { + return ""; + } + return ( + "[br][/br][br][/br]On the Remastered edition the merger is still required: only mods " + + "written against the new scope-based overrides merge themselves, and the published " + + "catalogue ships whole-file script overrides. Merged output is compiled against this " + + "edition's scripts, so merges made for an earlier edition cannot be reused." + ); +} + +/** + * @param force raise the notification even on editions we normally stay quiet + * on, for when the user explicitly asked to run the merger. + */ +export function notifyMissingScriptMerger(api, force: boolean = false) { + if (!force && !shouldNotifyMissingScriptMerger(api)) { + return; + } const notifId = "missing-script-merger"; api.sendNotification({ id: notifId, @@ -104,12 +149,13 @@ export function notifyMissingScriptMerger(api) { "info", "Witcher 3 Script Merger", { - bbcode: api.translate( - "Vortex is unable to resolve the Script Merger's location. The tool needs to be downloaded and configured manually. " + - "[url=https://wiki.nexusmods.com/index.php/Tool_Setup:_Witcher_3_Script_Merger]Find out more about how to configure it as a tool for use in Vortex.[/url][br][/br][br][/br]" + - "Note: While script merging works well with the vast majority of mods, there is no guarantee for a satisfying outcome in every single case.", - { ns: I18N_NAMESPACE }, - ), + bbcode: + api.translate( + "Vortex is unable to resolve the Script Merger's location. The tool needs to be downloaded and configured manually. " + + "[url=https://wiki.nexusmods.com/index.php/Tool_Setup:_Witcher_3_Script_Merger]Find out more about how to configure it as a tool for use in Vortex.[/url][br][/br][br][/br]" + + "Note: While script merging works well with the vast majority of mods, there is no guarantee for a satisfying outcome in every single case.", + { ns: I18N_NAMESPACE }, + ) + api.translate(remasteredMergerAdvice(api), { ns: I18N_NAMESPACE }), }, [ { @@ -158,10 +204,9 @@ export async function findModFolders(installationPath: string, mod: types.IMod): path.join(installationPath, mod.installationPath), (entries: IEntry[]) => { entries.forEach((entry) => { - const segments = entry.filePath.split(path.sep); - const contentIdx = segments.findIndex((seg) => seg.toLowerCase() === "content"); - if (![-1, 0].includes(contentIdx)) { - validNames.add(segments[contentIdx - 1]); + const folderName = modFolderNameFromPath(entry.filePath); + if (folderName !== undefined) { + validNames.add(folderName); } }); }, @@ -302,15 +347,17 @@ export function isLockedEntry(modName: string) { } export function determineExecutable(discoveredPath: string): string { - if (discoveredPath !== undefined) { + return determineExecutableFrom((relPath) => { + if (discoveredPath === undefined) { + return false; + } try { - fs.statSync(path.join(discoveredPath, "bin", "x64_DX12", "witcher3.exe")); - return "bin/x64_DX12/witcher3.exe"; + fs.statSync(path.join(discoveredPath, relPath)); + return true; } catch (err) { - // nop, use fallback + return false; } - } - return "bin/x64/witcher3.exe"; + }); } export function forceRefresh(api: types.IExtensionApi) { From 9bf9618298827871fad6267f4571380ca2ac16d5 Mon Sep 17 00:00:00 2001 From: IDCs Date: Mon, 28 Sep 2026 10:27:27 +0100 Subject: [PATCH 03/10] Keep the script merger pointed at the discovered install Every edition is managed under one game entry, so the merger tool and its configuration survive a switch between installs. The stored path was accepted on existence alone, so a merger belonging to another install passed and would have merged the wrong vanilla scripts into the wrong Mods folder. - Reject a tool path outside the discovered game root, re-point it when a merger is present there, and clear it when one is not. - Re-run the merger configuration on activation so its recorded directories match the install. - Record how each mod changes scripts as an attribute at install time, and suggest the merger only once two enabled mods ship whole-file scripts. Mods using scope annotations never claim the same file. part of LAZ-1011 --- .../game-witcher3/src/mergerPaths.test.ts | 44 ++++++ .../games/game-witcher3/src/mergerPaths.ts | 43 ++++++ .../game-witcher3/src/scriptStyle.test.ts | 131 ++++++++++++++++++ .../games/game-witcher3/src/scriptStyle.ts | 77 ++++++++++ .../games/game-witcher3/src/scriptmerger.ts | 78 ++++++++++- 5 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 extensions/games/game-witcher3/src/mergerPaths.test.ts create mode 100644 extensions/games/game-witcher3/src/mergerPaths.ts create mode 100644 extensions/games/game-witcher3/src/scriptStyle.test.ts create mode 100644 extensions/games/game-witcher3/src/scriptStyle.ts diff --git a/extensions/games/game-witcher3/src/mergerPaths.test.ts b/extensions/games/game-witcher3/src/mergerPaths.test.ts new file mode 100644 index 0000000000..365d012c73 --- /dev/null +++ b/extensions/games/game-witcher3/src/mergerPaths.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { isMergerToolValidForRoot, isPathWithinRoot, mergerDirForRoot } from "./mergerPaths"; + +const GOG = "C:\\Games\\GOG Galaxy\\Games\\The Witcher 3 Wild Hunt GOTY"; +const STEAM = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\The Witcher 3"; + +describe("isMergerToolValidForRoot", () => { + it("accepts a merger installed under the discovered game root", () => { + const tool = `${STEAM}\\WitcherScriptMerger\\WitcherScriptMerger.exe`; + expect(isMergerToolValidForRoot(tool, STEAM)).toBe(true); + }); + + it("rejects a merger belonging to the other install", () => { + // Both editions are managed under one game entry, and the other install is + // still on disk, so the stale path stats fine. + const tool = `${GOG}\\WitcherScriptMerger\\WitcherScriptMerger.exe`; + expect(isMergerToolValidForRoot(tool, STEAM)).toBe(false); + }); + + it("ignores case and trailing separators", () => { + const tool = `${STEAM.toUpperCase()}\\witcherscriptmerger\\WitcherScriptMerger.exe`; + expect(isMergerToolValidForRoot(tool, `${STEAM}\\`)).toBe(true); + }); + + it("does not treat a sibling directory with a shared prefix as inside the root", () => { + expect(isPathWithinRoot(`${STEAM} Remastered\\x.exe`, STEAM)).toBe(false); + }); + + it("treats the root itself as within the root", () => { + expect(isPathWithinRoot(STEAM, STEAM)).toBe(true); + }); + + it("rejects an undefined path or root", () => { + expect(isMergerToolValidForRoot(undefined, STEAM)).toBe(false); + expect(isMergerToolValidForRoot(`${STEAM}\\x.exe`, undefined)).toBe(false); + }); +}); + +describe("mergerDirForRoot", () => { + it("places the merger inside the game root", () => { + expect(mergerDirForRoot(STEAM)).toBe(`${STEAM}\\WitcherScriptMerger`); + }); +}); diff --git a/extensions/games/game-witcher3/src/mergerPaths.ts b/extensions/games/game-witcher3/src/mergerPaths.ts new file mode 100644 index 0000000000..e454ab5516 --- /dev/null +++ b/extensions/games/game-witcher3/src/mergerPaths.ts @@ -0,0 +1,43 @@ +import path from "node:path"; + +/** Directory the script merger is installed into, relative to the game root. */ +export const MERGER_RELPATH = "WitcherScriptMerger"; + +const normalise = (input: string) => + path + .normalize(input) + .replace(/[\\/]+$/, "") + .toLowerCase(); + +/** + * Whether a path sits inside the given root. Every edition shares one game + * entry, so a stored tool path can belong to a different install than the one + * discovered; existing on disk is not enough to make it the right one. + */ +export function isPathWithinRoot(candidate: string | undefined, root: string | undefined): boolean { + if (candidate === undefined || root === undefined) { + return false; + } + const normalisedRoot = normalise(root); + const normalisedCandidate = normalise(candidate); + return ( + normalisedCandidate === normalisedRoot || + normalisedCandidate.startsWith(normalisedRoot + path.sep) + ); +} + +/** Where the merger belongs for a given game root. */ +export function mergerDirForRoot(gameRoot: string): string { + return path.join(gameRoot, MERGER_RELPATH); +} + +/** + * The merger keeps its merge state beside its exe and compiles against one + * install's vanilla scripts, so each game root needs its own copy. + */ +export function isMergerToolValidForRoot( + toolPath: string | undefined, + gameRoot: string | undefined, +): boolean { + return isPathWithinRoot(toolPath, gameRoot); +} diff --git a/extensions/games/game-witcher3/src/scriptStyle.test.ts b/extensions/games/game-witcher3/src/scriptStyle.test.ts new file mode 100644 index 0000000000..5db808af43 --- /dev/null +++ b/extensions/games/game-witcher3/src/scriptStyle.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyScripts, + isAnnotationScript, + isScript, + needsScriptMerger, + shouldSuggestScriptMerger, + styleOf, + W3ScriptStyle, +} from "./scriptStyle"; + +const WRAPPER = ` +@wrapMethod(W3LevelManager) +function AddPoints(type : ESpendablePointType, amount : int, show : bool) +{ + wrappedMethod(type, amount * 5, show); +} +`; + +const WHOLE_FILE = ` +class W3LevelManager +{ + public function AddPoints(type : ESpendablePointType, amount : int, show : bool) + { + points[type].free += amount; + } +} +`; + +describe("isScript", () => { + it.each(["a/b/foo.ws", "FOO.WS"])("recognises %s", (path) => { + expect(isScript(path)).toBe(true); + }); + + it.each(["foo.xml", "blob0.bundle", "readme.txt"])("ignores %s", (path) => { + expect(isScript(path)).toBe(false); + }); +}); + +describe("isAnnotationScript", () => { + it.each(["@wrapMethod", "@replaceMethod", "@addMethod", "@addField"])( + "recognises %s", + (annotation) => { + expect(isAnnotationScript(`${annotation}(W3PlayerWitcher)\nfunction Foo() {}`)).toBe(true); + }, + ); + + it("does not match a whole file copy", () => { + expect(isAnnotationScript(WHOLE_FILE)).toBe(false); + }); + + it("does not match a name that merely starts the same", () => { + expect(isAnnotationScript("@wrapMethodology(X)")).toBe(false); + }); +}); + +describe("classifyScripts", () => { + it("reports no scripts for a mod that ships none", () => { + expect(classifyScripts([])).toBe(W3ScriptStyle.None); + }); + + it("reports annotation when every script declares its target", () => { + expect(classifyScripts([{ contents: WRAPPER }, { contents: WRAPPER }])).toBe( + W3ScriptStyle.Annotation, + ); + }); + + it("reports override for a whole file copy", () => { + expect(classifyScripts([{ contents: WHOLE_FILE }])).toBe(W3ScriptStyle.Override); + }); + + it("reports override when a mod mixes both", () => { + // The plain file can still collide with another mod's copy of it. + expect(classifyScripts([{ contents: WRAPPER }, { contents: WHOLE_FILE }])).toBe( + W3ScriptStyle.Override, + ); + }); +}); + +describe("needsScriptMerger", () => { + it("is true only for mods shipping a whole file copy", () => { + expect(needsScriptMerger(W3ScriptStyle.Override)).toBe(true); + expect(needsScriptMerger(W3ScriptStyle.Annotation)).toBe(false); + expect(needsScriptMerger(W3ScriptStyle.None)).toBe(false); + }); +}); + +describe("styleOf", () => { + it.each([W3ScriptStyle.Annotation, W3ScriptStyle.None, W3ScriptStyle.Override])( + "keeps a recorded style of %s", + (style) => { + expect(styleOf(style)).toBe(style); + }, + ); + + it.each([undefined, null, "", "nonsense", 7])("treats %s as an override", (value) => { + // Mods installed before the attribute existed carry no style. + expect(styleOf(value)).toBe(W3ScriptStyle.Override); + }); +}); + +describe("shouldSuggestScriptMerger", () => { + it("stays quiet for a single override mod", () => { + // Nothing to merge until a second mod claims the same script. + expect(shouldSuggestScriptMerger([W3ScriptStyle.Override])).toBe(false); + }); + + it("suggests it once two override mods are present", () => { + expect(shouldSuggestScriptMerger([W3ScriptStyle.Override, W3ScriptStyle.Override])).toBe(true); + }); + + it("ignores annotation and script-free mods", () => { + expect( + shouldSuggestScriptMerger([ + W3ScriptStyle.Annotation, + W3ScriptStyle.Annotation, + W3ScriptStyle.None, + W3ScriptStyle.Override, + ]), + ).toBe(false); + }); + + it("stays quiet when nothing is installed", () => { + expect(shouldSuggestScriptMerger([])).toBe(false); + }); + + it("counts mods that predate the attribute", () => { + expect(shouldSuggestScriptMerger([undefined, undefined])).toBe(true); + }); +}); diff --git a/extensions/games/game-witcher3/src/scriptStyle.ts b/extensions/games/game-witcher3/src/scriptStyle.ts new file mode 100644 index 0000000000..8f55ba1d93 --- /dev/null +++ b/extensions/games/game-witcher3/src/scriptStyle.ts @@ -0,0 +1,77 @@ +/** + * How a mod changes game scripts. Recorded as a mod attribute at install time, + * so later checks read a value instead of the file system. Kept free of Vortex + * API imports so it can be unit tested. + */ + +export const W3ScriptStyle = { + /** Ships no scripts. */ + None: "none", + /** Every script declares its target with a scope annotation. */ + Annotation: "annotation", + /** Ships at least one script that replaces a whole file. */ + Override: "override", +} as const; +export type W3ScriptStyle = (typeof W3ScriptStyle)[keyof typeof W3ScriptStyle]; + +/** Mod attribute the style is stored under. */ +export const SCRIPT_STYLE_ATTRIBUTE = "w3ScriptStyle"; + +export const SCRIPT_EXTENSION = ".ws"; + +/** + * Scope annotations name the class and function a mod changes, so two mods + * using them never claim the same file and never need merging. + */ +const ANNOTATION = /@(wrapMethod|replaceMethod|addMethod|addField)\b/; + +export const isScript = (filePath: string): boolean => + filePath.toLowerCase().endsWith(SCRIPT_EXTENSION); + +export const isAnnotationScript = (contents: string): boolean => + contents !== undefined && ANNOTATION.test(contents); + +/** + * Classifies a mod from the scripts it ships. A single plain script makes the + * whole mod an override, since that one file can collide with another mod's + * copy whether or not the rest use annotations. + */ +export function classifyScripts(scripts: Array<{ contents: string }>): W3ScriptStyle { + if (scripts.length === 0) { + return W3ScriptStyle.None; + } + return scripts.every((script) => isAnnotationScript(script.contents)) + ? W3ScriptStyle.Annotation + : W3ScriptStyle.Override; +} + +/** + * Whether a mod of this style can collide with another mod over a script file, + * which is the only thing the script merger resolves. + */ +export const needsScriptMerger = (style: W3ScriptStyle): boolean => + style === W3ScriptStyle.Override; + +/** + * Style recorded against a mod. Anything unrecognised counts as an override, + * since the attribute is only written at install time and a mod without one + * could be either. + */ +export const styleOf = (attributeValue: unknown): W3ScriptStyle => + attributeValue === W3ScriptStyle.Annotation || attributeValue === W3ScriptStyle.None + ? attributeValue + : W3ScriptStyle.Override; + +/** + * Whether the script merger is worth suggesting. It has nothing to do until two + * mods claim the same script. Reads recorded styles, never the file system. + */ +export function shouldSuggestScriptMerger(recordedStyles: unknown[]): boolean { + let overrides = 0; + for (const value of recordedStyles) { + if (needsScriptMerger(styleOf(value)) && ++overrides > 1) { + return true; + } + } + return false; +} diff --git a/extensions/games/game-witcher3/src/scriptmerger.ts b/extensions/games/game-witcher3/src/scriptmerger.ts index 2da2460b38..972aceacc8 100644 --- a/extensions/games/game-witcher3/src/scriptmerger.ts +++ b/extensions/games/game-witcher3/src/scriptmerger.ts @@ -9,11 +9,12 @@ import _ from "lodash"; import semver from "semver"; import { Builder, parseStringPromise } from "xml2js"; +import { isMergerToolValidForRoot, mergerDirForRoot } from "./mergerPaths"; import { IIncomingGithubHttpHeaders } from "./types"; +import { shouldNotifyMissingScriptMerger } from "./util"; const RELEASE_CUTOFF = "0.6.5"; const GITHUB_URL = "https://api.github.com/repos/IDCs/WitcherScriptMerger"; -const MERGER_RELPATH = "WitcherScriptMerger"; const MERGER_CONFIG_FILE = "WitcherScriptMerger.exe.config"; @@ -213,10 +214,15 @@ export async function getScriptMergerDir(api, create = false) { if (!currentPath) { throw new Error("Script Merger not set up"); } + // Existing on disk isn't enough: a path left over from another install + // stats fine and would merge the wrong scripts into the wrong Mods folder. + if (!isMergerToolValidForRoot(currentPath, discovery.path)) { + throw new util.ProcessCanceled("Script Merger belongs to another install"); + } await fs.statAsync(currentPath); return currentPath; } catch (err) { - const defaultPath = path.join(discovery.path, MERGER_RELPATH); + const defaultPath = mergerDirForRoot(discovery.path); if (create) { await fs.ensureDirWritableAsync(defaultPath); } @@ -402,6 +408,10 @@ export async function downloadScriptMerger(api: types.IExtensionApi) { .catch(async (err) => { const raiseManualInstallNotif = () => { log("error", "Failed to automatically install Script Merger", err.errorMessage); + if (!shouldNotifyMissingScriptMerger(api)) { + // Nothing to chase on the remaster - mods there merge themselves. + return; + } api.sendNotification({ type: "error", message: api.translate("Please install Script Merger manually", { ns: "game-witcher3" }), @@ -490,6 +500,65 @@ async function setUpMerger(api, mergerVersion, newPath) { return Promise.resolve(); } +/** + * Points the script merger at the install that is currently discovered. + * + * Both editions are managed under one game entry, so the stored tool and the + * merger's own config survive a switch between them. The merger compiles against + * one edition's vanilla scripts and keeps its merge state in MergeInventory.xml + * beside its exe, so each game root gets its own copy rather than sharing one. + */ +export async function repairStaleScriptMerger( + api: types.IExtensionApi, + discovery: types.IDiscoveryResult, +): Promise { + const toolPath = discovery?.tools?.[SCRIPT_MERGER_ID]?.path; + if (discovery?.path === undefined || toolPath === undefined) { + return; + } + + const expectedDir = mergerDirForRoot(discovery.path); + if (isMergerToolValidForRoot(toolPath, discovery.path)) { + // Right merger, but its config records whichever root it was set up for. + await setMergerConfig(discovery.path, path.dirname(toolPath)); + return; + } + + const expectedExe = path.join(expectedDir, "WitcherScriptMerger.exe"); + const installed = await fs + .statAsync(expectedExe) + .then(() => true) + .catch(() => false); + + log("info", "witcher3 script merger belongs to another install, re-pointing", { + from: toolPath, + to: expectedDir, + installed, + }); + + // Pointed at the executable only when it is there. Naming a path that is not + // leaves the tool looking configured, so it fails on run and the download + // treats it as already current. + const newToolDetails = { + ...discovery.tools[SCRIPT_MERGER_ID], + path: installed ? expectedExe : undefined, + workingDirectory: installed ? expectedDir : undefined, + mergerVersion: installed ? discovery.tools[SCRIPT_MERGER_ID].mergerVersion : undefined, + }; + api.store.dispatch(actions.addDiscoveredTool("witcher3", SCRIPT_MERGER_ID, newToolDetails, true)); + + if (installed) { + await setMergerConfig(discovery.path, expectedDir); + } else { + api.sendNotification({ + id: "w3-merger-repointed", + type: "info", + message: "Script Merger needs setting up again for this copy of the game.", + allowSuppress: false, + }); + } +} + export async function getMergedModName(scriptMergerPath) { const configFilePath = path.join(scriptMergerPath, MERGER_CONFIG_FILE); try { @@ -508,8 +577,11 @@ export async function getMergedModName(scriptMergerPath) { } export async function setMergerConfig(gameRootPath, scriptMergerPath) { + // findIndex reports a miss as -1, which is a valid-looking array index right + // up until the assignment throws and leaves the config half rewritten. const findIndex = (nodes, id) => { - return nodes?.findIndex((iter) => iter.$?.key === id) ?? undefined; + const idx = nodes?.findIndex((iter) => iter.$?.key === id) ?? -1; + return idx >= 0 ? idx : undefined; }; const configFilePath = path.join(scriptMergerPath, MERGER_CONFIG_FILE); From 8285ab2a451b3c6a8c4309bd158b9c27a207ae69 Mon Sep 17 00:00:00 2001 From: IDCs Date: Mon, 28 Sep 2026 10:27:43 +0100 Subject: [PATCH 04/10] Fix load order and merge state being lost or reordered - Stop sorting collection load orders by data.prefix, which is only refreshed on deploy, so a reorder exported without deploying came out wrong. The field is still written, so the payload is unchanged for older clients. - Give every mod a distinct priority in mods.settings. Mods absent from the load order all received the same number, and a purge made every mod absent, so the following deploy produced an arbitrary order. - Fall back to the profile's stored load order when mods.settings is missing, rather than replacing it with an empty one. - Restore MergeInventory.xml only alongside the merged scripts it describes. - Sort the load order alphabetically by folder name, which is the order the game falls back to, and persist it before refreshing. part of LAZ-1011 --- .../src/collectionLoadOrder.test.ts | 72 +++++++++++++ .../game-witcher3/src/collectionLoadOrder.ts | 41 +++++++ .../game-witcher3/src/collections/util.ts | 54 ++-------- .../games/game-witcher3/src/iconbarActions.ts | 100 ++++++++--------- .../games/game-witcher3/src/iniParser.ts | 54 ++++------ .../games/game-witcher3/src/loadOrder.tsx | 9 +- .../src/loadOrderPriority.test.ts | 29 +++++ .../game-witcher3/src/loadOrderPriority.ts | 29 +++++ extensions/games/game-witcher3/src/menumod.ts | 41 ++++++- .../games/game-witcher3/src/mergeBackup.ts | 70 +++++++++++- .../src/modSettingsPriority.test.ts | 102 ++++++++++++++++++ .../game-witcher3/src/modSettingsPriority.ts | 80 ++++++++++++++ .../game-witcher3/src/priorityManager.ts | 15 +-- 13 files changed, 542 insertions(+), 154 deletions(-) create mode 100644 extensions/games/game-witcher3/src/collectionLoadOrder.test.ts create mode 100644 extensions/games/game-witcher3/src/collectionLoadOrder.ts create mode 100644 extensions/games/game-witcher3/src/loadOrderPriority.test.ts create mode 100644 extensions/games/game-witcher3/src/loadOrderPriority.ts create mode 100644 extensions/games/game-witcher3/src/modSettingsPriority.test.ts create mode 100644 extensions/games/game-witcher3/src/modSettingsPriority.ts diff --git a/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts b/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts new file mode 100644 index 0000000000..a7e24fdc43 --- /dev/null +++ b/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts @@ -0,0 +1,72 @@ +import { types } from "@nexusmods/vortex-api"; +import { describe, expect, it } from "vitest"; + +import { genCollectionLoadOrder } from "./collectionLoadOrder"; + +const entry = (modId: string, prefix: number): types.IFBLOLoadOrderEntry => ({ + id: modId, + modId, + name: modId, + enabled: true, + data: { prefix }, +}); + +const mod = (id: string, type = ""): types.IMod => ({ + id, + type, + state: "installed", + installationPath: id, + attributes: {}, +}); + +// key: mod id +const mods = (...ids: string[]): Record => + Object.fromEntries(ids.map((id) => [id, mod(id)])); + +const modIds = (loadOrder: types.LoadOrder) => loadOrder.map((item) => item.modId); + +describe("genCollectionLoadOrder", () => { + it("keeps the load order in the order the entries arrive in", () => { + // data.prefix is only refreshed on deploy, so an entry moved on the load + // order page still carries the prefix it had in its old position. + const loadOrder = [entry("modA", 3), entry("modB", 1), entry("modC", 2)]; + + const result = genCollectionLoadOrder(loadOrder, mods("modA", "modB", "modC")); + + expect(modIds(result)).toEqual(["modA", "modB", "modC"]); + }); + + it("keeps locked entries that have no corresponding mod", () => { + const loadOrder = [entry("mod0000____CompilationTrigger", 1), entry("modA", 2)]; + + const result = genCollectionLoadOrder(loadOrder, mods("modA")); + + expect(modIds(result)).toEqual(["mod0000____CompilationTrigger", "modA"]); + }); + + it("drops entries whose mod is not managed", () => { + const loadOrder = [entry("modA", 1), entry("modGone", 2), entry("modB", 3)]; + + const result = genCollectionLoadOrder(loadOrder, mods("modA", "modB")); + + expect(modIds(result)).toEqual(["modA", "modB"]); + }); + + it("drops collections", () => { + const loadOrder = [entry("modA", 1), entry("someCollection", 2)]; + const available = mods("modA"); + available.someCollection = mod("someCollection", "collection"); + + const result = genCollectionLoadOrder(loadOrder, available); + + expect(modIds(result)).toEqual(["modA"]); + }); + + it("carries the prefix through untouched", () => { + const loadOrder = [entry("modA", 7)]; + + const result = genCollectionLoadOrder(loadOrder, mods("modA")); + + expect(result[0].data.prefix).toBe(7); + }); +}); diff --git a/extensions/games/game-witcher3/src/collectionLoadOrder.ts b/extensions/games/game-witcher3/src/collectionLoadOrder.ts new file mode 100644 index 0000000000..73f9b44c65 --- /dev/null +++ b/extensions/games/game-witcher3/src/collectionLoadOrder.ts @@ -0,0 +1,41 @@ +import { types, util } from "@nexusmods/vortex-api"; + +import { LOCKED_PREFIX } from "./common"; + +export function isValidMod(mod: types.IMod) { + return mod !== undefined && mod.type !== "collection"; +} + +export function isModInCollection(collectionMod: types.IMod, mod: types.IMod) { + if (collectionMod.rules === undefined) { + return false; + } + + return ( + collectionMod.rules.find((rule) => util.testModReference(mod, rule.reference)) !== undefined + ); +} + +/** + * Reduces the profile's load order down to the entries a collection should + * carry. The array order is the load order, so the entries are kept in the + * order they arrive in. + */ +export function genCollectionLoadOrder( + loadOrder: types.IFBLOLoadOrderEntry[], + mods: Record, // key: mod id + collection?: types.IMod, +): types.LoadOrder { + return loadOrder.filter((entry) => { + const modId = entry.modId; + if (modId === undefined) { + return false; + } + if (modId.includes(LOCKED_PREFIX)) { + return true; + } + return collection !== undefined + ? isValidMod(mods[modId]) && isModInCollection(collection, mods[modId]) + : isValidMod(mods[modId]); + }); +} diff --git a/extensions/games/game-witcher3/src/collections/util.ts b/extensions/games/game-witcher3/src/collections/util.ts index 7982769fe4..24b09a6952 100644 --- a/extensions/games/game-witcher3/src/collections/util.ts +++ b/extensions/games/game-witcher3/src/collections/util.ts @@ -1,11 +1,14 @@ /** eslint-disable */ import path from "path"; -import { fs, log, types, util } from "@nexusmods/vortex-api"; +import { fs, log, util } from "@nexusmods/vortex-api"; import { generate } from "shortid"; import turbowalk, { IEntry } from "turbowalk"; -import { LOCKED_PREFIX, W3_TEMP_DATA_DIR } from "../common"; +import { genCollectionLoadOrder, isModInCollection, isValidMod } from "../collectionLoadOrder"; +import { getW3TempDataDir } from "../common"; + +export { genCollectionLoadOrder, isModInCollection, isValidMod }; export class CollectionGenerateError extends Error { constructor(why: string) { @@ -21,43 +24,6 @@ export class CollectionParseError extends Error { } } -export function isValidMod(mod: types.IMod) { - return mod !== undefined && mod.type !== "collection"; -} - -export function isModInCollection(collectionMod: types.IMod, mod: types.IMod) { - if (collectionMod.rules === undefined) { - return false; - } - - return ( - collectionMod.rules.find((rule) => util.testModReference(mod, rule.reference)) !== undefined - ); -} - -export function genCollectionLoadOrder( - loadOrder: types.IFBLOLoadOrderEntry[], - mods: { [modId: string]: types.IMod }, - collection?: types.IMod, -): types.LoadOrder { - const sortedMods = loadOrder - .filter((entry) => { - const isLocked = entry.modId.includes(LOCKED_PREFIX); - return ( - isLocked || - (collection !== undefined - ? isValidMod(mods[entry.modId]) && isModInCollection(collection, mods[entry.modId]) - : isValidMod(mods[entry.modId])) - ); - }) - .sort((lhs, rhs) => lhs.data.prefix - rhs.data.prefix) - .reduce((accum, iter, idx) => { - accum.push(iter); - return accum; - }, []); - return sortedMods; -} - export async function walkDirPath(dirPath: string): Promise { let fileEntries: IEntry[] = []; await turbowalk(dirPath, (entries: IEntry[]) => { @@ -74,8 +40,8 @@ export async function walkDirPath(dirPath: string): Promise { export async function prepareFileData(dirPath: string): Promise { const sevenZip = new util.SevenZip(); try { - await fs.ensureDirWritableAsync(W3_TEMP_DATA_DIR); - const archivePath = path.join(W3_TEMP_DATA_DIR, generate() + ".zip"); + await fs.ensureDirWritableAsync(getW3TempDataDir()); + const archivePath = path.join(getW3TempDataDir(), generate() + ".zip"); const entries: string[] = await fs.readdirAsync(dirPath); await sevenZip.add( archivePath, @@ -107,10 +73,10 @@ export async function restoreFileData(fileData: Buffer, destination: string): Pr let archivePath; let fileEntries: IEntry[] = []; try { - await fs.ensureDirWritableAsync(W3_TEMP_DATA_DIR); - archivePath = path.join(W3_TEMP_DATA_DIR, generate() + ".zip"); + await fs.ensureDirWritableAsync(getW3TempDataDir()); + archivePath = path.join(getW3TempDataDir(), generate() + ".zip"); await fs.writeFileAsync(archivePath, fileData); - const targetDirPath = path.join(W3_TEMP_DATA_DIR, path.basename(archivePath, ".zip")); + const targetDirPath = path.join(getW3TempDataDir(), path.basename(archivePath, ".zip")); await sevenZip.extractFull(archivePath, targetDirPath); fileEntries = await walkDirPath(targetDirPath); for (const entry of fileEntries) { diff --git a/extensions/games/game-witcher3/src/iconbarActions.ts b/extensions/games/game-witcher3/src/iconbarActions.ts index a2c9566615..9657a44bac 100644 --- a/extensions/games/game-witcher3/src/iconbarActions.ts +++ b/extensions/games/game-witcher3/src/iconbarActions.ts @@ -3,12 +3,13 @@ import path from "path"; import { actions, selectors, types, util } from "@nexusmods/vortex-api"; -import { GAME_ID, I18N_NAMESPACE, LOCKED_PREFIX } from "./common"; +import { GAME_ID, I18N_NAMESPACE } from "./common"; +import IniStructure from "./iniParser"; import TW3LoadOrder, { importLoadOrder } from "./loadOrder"; import { makeOnContextImport } from "./mergeBackup"; import { getPersistentLoadOrder } from "./migrations"; import { PriorityManager } from "./priorityManager"; -import { forceRefresh } from "./util"; +import { forceRefresh, isLockedEntry } from "./util"; interface IProps { context: types.IExtensionContext; @@ -106,18 +107,18 @@ export const registerActions = (props: IProps) => { 100, "loot-sort", {}, - "Sort by Deploy Order", + "Sort Alphabetically", () => { context.api.showDialog( "info", - "Sort by Deployment Order", + "Sort Alphabetically", { bbcode: context.api.translate( - "This action will set priorities using the deployment rules " + - "defined in the mods page. Are you sure you wish to proceed ?[br][/br][br][/br]" + - "Please be aware that any externally added mods (added manually or by other tools) will be pushed " + - "to the bottom of the list, while all mods that have been installed through Vortex will shift " + - "in position to match the deploy order!", + "This action will set priorities by sorting mod folder names alphabetically, " + + "which is the order the game itself uses when no priority is set. " + + "Are you sure you wish to proceed ?[br][/br][br][/br]" + + "Merged scripts stay locked at the top. Everything else is reordered, including " + + "mods added manually or by other tools.", { ns: I18N_NAMESPACE }, ), }, @@ -129,55 +130,40 @@ export const registerActions = (props: IProps) => { }, }, { - label: "Sort by Deploy Order", - action: () => { - const state = context.api.getState(); - const gameMods = state.persistent.mods?.[GAME_ID] || {}; - const profile = selectors.activeProfile(state); - const mods = Object.keys(gameMods) - .filter((key) => util.getSafe(profile, ["modState", key, "enabled"], false)) - .map((key) => gameMods[key]); - const findIndex = (entry: types.ILoadOrderEntry, modList: types.IMod[]) => { - return modList.findIndex((m) => m.id === entry.modId); - }; - return util - .sortMods(GAME_ID, mods, context.api) - .then((sorted) => { - const loadOrder = getPersistentLoadOrder(context.api); - const filtered = loadOrder.filter( - (entry) => sorted.find((mod) => mod.id === entry.id) !== undefined, - ); - const sortedLO = filtered.sort( - (a, b) => findIndex(a, sorted) - findIndex(b, sorted), - ); - const locked = loadOrder.filter((entry) => entry.name.includes(LOCKED_PREFIX)); - const manuallyAdded = loadOrder.filter( - (key) => !filtered.includes(key) && !locked.includes(key), - ); - const newLO = [...locked, ...sortedLO, ...manuallyAdded].reduce( - (accum, entry, idx) => { - accum.push({ - ...entry, - data: { - prefix: idx + 1, - }, - }); - return accum; - }, - [], - ); + label: "Sort Alphabetically", + action: async () => { + try { + const profile = selectors.activeProfile(context.api.getState()); + const loadOrder = getPersistentLoadOrder(context.api); + // Match on the folder name; the display name is the mod's Nexus + // title, which doesn't carry the locked prefix. + const locked = loadOrder.filter((entry) => isLockedEntry(entry.id)); + const sortable = loadOrder.filter((entry) => !isLockedEntry(entry.id)); + + // The game falls back to ordering mod folders by name, and authors + // name their mods to win or lose overrides on that basis. + const sorted = [...sortable].sort((lhs, rhs) => + lhs.id.toLowerCase().localeCompare(rhs.id.toLowerCase()), + ); + + const newLO = [...locked, ...sorted].map((entry, idx) => ({ + ...entry, + data: { + prefix: idx + 1, + }, + })); - context.api.store.dispatch(actions.setLoadOrder(profile.id, newLO as any)); - }) - .catch((err) => { - const allowReport = !(err instanceof util.CycleError); - context.api.showErrorNotification("Failed to sort by deployment order", err, { - allowReport, - }); - }) - .finally(() => { - forceRefresh(context.api); - }); + context.api.store.dispatch(actions.setLoadOrder(profile.id, newLO as any)); + // The refresh below makes the page re-read mods.settings, so the + // new order has to reach the file first or it's just discarded. + await IniStructure.getInstance(context.api, props.getPriorityManager).setINIStruct( + newLO as any, + ); + } catch (err) { + context.api.showErrorNotification("Failed to sort alphabetically", err); + } finally { + forceRefresh(context.api); + } }, }, ], diff --git a/extensions/games/game-witcher3/src/iniParser.ts b/extensions/games/game-witcher3/src/iniParser.ts index 4d61bd67ba..9b3262639e 100644 --- a/extensions/games/game-witcher3/src/iniParser.ts +++ b/extensions/games/game-witcher3/src/iniParser.ts @@ -5,6 +5,7 @@ import { fs, selectors, types, util } from "@nexusmods/vortex-api"; import IniParser, { IniFile, WinapiFormat } from "vortex-parse-ini"; import { GAME_ID, ResourceInaccessibleError, getLoadOrderFilePath } from "./common"; +import { assignPriorities, ModSettingsEntry } from "./modSettingsPriority"; import { PriorityManager } from "./priorityManager"; import { forceRefresh, isLockedEntry, getAllMods, getManuallyAddedMods } from "./util"; @@ -38,45 +39,32 @@ export default class IniStructure { public async setINIStruct(loadOrder: types.LoadOrder) { const modMap = await getAllMods(this.mApi); - this.mIniStruct = {}; - const mods = [].concat(modMap.merged, modMap.managed, modMap.manual); + const mods: ModSettingsEntry[] = [...modMap.merged, ...modMap.managed, ...modMap.manual]; const manualLocked = modMap.manual.filter(isLockedEntry); const managedLocked = modMap.managed .filter((entry) => isLockedEntry(entry.name)) .map((entry) => entry.name); - const totalLocked = [].concat(modMap.merged, manualLocked, managedLocked); - this.mIniStruct = mods.reduce((accum, mod, idx) => { - let name; - let key; - if (typeof mod === "object" && !!mod) { - name = mod.name; - key = mod.id; - } else { - name = mod; - key = mod; - } - - if (name.toLowerCase().startsWith("dlc")) { - return accum; - } - - const idxOfEntry = (loadOrder || []).findIndex((iter) => iter.id === name); - const LOEntry = loadOrder.at(idxOfEntry); - if (idx === 0) { - this.mPriorityManager?.resetMaxPriority(totalLocked.length); - } - accum[name] = { + const totalLocked: string[] = [...modMap.merged, ...manualLocked, ...managedLocked]; + this.mPriorityManager?.resetMaxPriority(totalLocked.length); + + const order = loadOrder ?? []; + const enabledByName = new Map(order.map((entry) => [entry.id, entry.enabled])); + const assigned = assignPriorities({ + mods, + locked: totalLocked, + loadOrderIds: order.map((entry) => entry.id), + }); + + const struct: Record = {}; + for (const entry of assigned) { + struct[entry.name] = { // The INI file's enabled attribute expects 1 or 0 - Enabled: LOEntry !== undefined ? (LOEntry.enabled ? 1 : 0) : 1, - Priority: totalLocked.includes(name) - ? totalLocked.indexOf(name) + 1 - : idxOfEntry === -1 - ? loadOrder.length + 1 - : idxOfEntry + totalLocked.length, - VK: key, + Enabled: enabledByName.get(entry.name) === false ? 0 : 1, + Priority: entry.priority, + VK: entry.key, }; - return accum; - }, {}); + } + this.mIniStruct = struct; return this.writeToModSettings(); } diff --git a/extensions/games/game-witcher3/src/loadOrder.tsx b/extensions/games/game-witcher3/src/loadOrder.tsx index 2a784bb167..a050a5dbcd 100644 --- a/extensions/games/game-witcher3/src/loadOrder.tsx +++ b/extensions/games/game-witcher3/src/loadOrder.tsx @@ -113,7 +113,14 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { const finalEntries = [].concat(entries.locked, entries.regular); return Promise.resolve(finalEntries); } catch (err) { - return; + // mods.settings is gone, which a purge does. Returning nothing here + // replaces the stored order with an empty one, so fall back to the + // order the profile already holds. + const persistent = state.persistent as typeof state.persistent & { + loadOrder?: Record; // key: profile id + }; + const stored = persistent.loadOrder?.[activeProfile.id]; + return Array.isArray(stored) ? stored : []; } } diff --git a/extensions/games/game-witcher3/src/loadOrderPriority.test.ts b/extensions/games/game-witcher3/src/loadOrderPriority.test.ts new file mode 100644 index 0000000000..e4f414efb7 --- /dev/null +++ b/extensions/games/game-witcher3/src/loadOrderPriority.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { maxPriorityFrom } from "./loadOrderPriority"; + +// Only the fields maxPriorityFrom reads. +const entry = (id: string, pos: number, prefix?: string) => + ({ id, pos, data: prefix === undefined ? {} : { prefix } }) as never; + +describe("maxPriorityFrom", () => { + it("reads the prefix from the entry's data", () => { + // This is the shape deserializeLoadOrder and the load order migration + // produce, so it's what nearly every entry actually looks like. + const loadOrder = [entry("a", 0, "42"), entry("b", 1, "7")]; + expect(maxPriorityFrom(loadOrder, 0)).toBe(42); + }); + + it("falls back to the position when an entry has no prefix", () => { + const loadOrder = [entry("a", 3), entry("b", 1)]; + expect(maxPriorityFrom(loadOrder, 0)).toBe(3); + }); + + it("never returns less than the supplied minimum", () => { + expect(maxPriorityFrom([entry("a", 1, "2")], 10)).toBe(10); + }); + + it("returns the minimum for an empty load order", () => { + expect(maxPriorityFrom([], 5)).toBe(5); + }); +}); diff --git a/extensions/games/game-witcher3/src/loadOrderPriority.ts b/extensions/games/game-witcher3/src/loadOrderPriority.ts new file mode 100644 index 0000000000..02f0d69906 --- /dev/null +++ b/extensions/games/game-witcher3/src/loadOrderPriority.ts @@ -0,0 +1,29 @@ +/** + * Load order priority arithmetic, kept free of imports so it can be unit + * tested without pulling in the Vortex API. + */ + +/** Only the fields the priority calculation reads. */ +interface IPriorityEntry { + pos: number; + prefix?: string; + data?: { prefix?: string }; +} + +/** + * Highest priority currently in use, so a newly added mod can be placed after + * it. Entries carry their prefix under `data`; the flat form is what load + * orders persisted by older versions look like. + */ +export function maxPriorityFrom(loadOrder: ArrayLike, minPriority: number): number { + return Object.keys(loadOrder).reduce((prev, key) => { + const entry = loadOrder[key]; + const prefixVal = entry?.data?.prefix ?? entry?.prefix; + const intVal = prefixVal !== undefined ? parseInt(prefixVal, 10) : entry.pos; + const posVal = entry.pos; + if (posVal !== intVal) { + return intVal > prev ? intVal : prev; + } + return posVal > prev ? posVal : prev; + }, minPriority); +} diff --git a/extensions/games/game-witcher3/src/menumod.ts b/extensions/games/game-witcher3/src/menumod.ts index 3aae70f0d4..5eadd2db90 100644 --- a/extensions/games/game-witcher3/src/menumod.ts +++ b/extensions/games/game-witcher3/src/menumod.ts @@ -8,6 +8,7 @@ import { generate } from "shortid"; import { prepareFileData, restoreFileData } from "./collections/util"; import { GAME_ID, INPUT_XML_FILENAME, PART_SUFFIX } from "./common"; +import { detectEdition, W3Edition } from "./edition"; import { getPersistentLoadOrder } from "./migrations"; import { getDeployment } from "./util"; @@ -18,6 +19,10 @@ const DX_11_USER_SETTINGS_FILENAME = "user.settings"; const DX_12_USER_SETTINGS_FILENAME = "dx12user.settings"; const BACKUP_TAG = ".vortex_backup"; +// The remaster keeps the master switches for local mods here. Mods must never +// supply it, and it has to survive regeneration of the settings file. +const CONTENT_MANAGER_MODS_SECTION = "ContentManager/Mods"; + interface ICacheEntry { id: string; filepath: string; @@ -451,6 +456,32 @@ const getInitialDoc = (filePath: string) => { }); }; +/** + * The generated settings file is rebuilt from a `.vortex_backup` snapshot, + * which on an install that was upgraded in place predates the remaster and so + * has no mod switches in it. Deploying that copy would turn every deployed mod + * off with no error anywhere, so the live values always win - including over + * anything a mod tried to supply. + */ +async function preserveContentManagerMods(parser, liveFilePath: string, target) { + let live; + try { + live = await parser.read(liveFilePath); + } catch (err) { + // Leave the generated copy alone rather than writing a file with no mod + // switches at all. + log("warn", "W3: could not read live mod switches", err.message); + return; + } + + const section = live.data[CONTENT_MANAGER_MODS_SECTION]; + if (section !== undefined) { + target.data[CONTENT_MANAGER_MODS_SECTION] = { ...section }; + } else { + delete target.data[CONTENT_MANAGER_MODS_SECTION]; + } +} + async function writeCacheToFiles(api, profile) { const state = api.store.getState(); const modName = menuMod(profile.name); @@ -467,7 +498,12 @@ async function writeCacheToFiles(api, profile) { if (!fileMap) return; const parser = new IniParser.default(new IniParser.WinapiFormat()); - const keys = Object.keys(fileMap); + const edition = detectEdition(discovery.path); + const keys = Object.keys(fileMap).filter( + // The remaster has no DirectX 11 renderer, so user.settings is never + // created and asking the user to run the game once can't help them. + (key) => !(edition === W3Edition.Remastered && key === DX_11_USER_SETTINGS_FILENAME), + ); for (const key of keys) { try { @@ -488,6 +524,9 @@ async function writeCacheToFiles(api, profile) { }; } } + if (key === DX_12_USER_SETTINGS_FILENAME) { + await preserveContentManagerMods(parser, path.join(docModPath, key), initialData); + } await parser.write(path.join(destinationFolder, key), initialData); } catch (err) { if ( diff --git a/extensions/games/game-witcher3/src/mergeBackup.ts b/extensions/games/game-witcher3/src/mergeBackup.ts index 7e6ef17777..092f3debf2 100644 --- a/extensions/games/game-witcher3/src/mergeBackup.ts +++ b/extensions/games/game-witcher3/src/mergeBackup.ts @@ -1,3 +1,4 @@ +import { readFile, writeFile } from "node:fs/promises"; import path from "path"; import { fs, log, selectors, types, util } from "@nexusmods/vortex-api"; @@ -12,9 +13,10 @@ import { getLoadOrderFilePath, MERGE_INV_MANIFEST, SCRIPT_MERGER_ID, - W3_TEMP_DATA_DIR, + getW3TempDataDir, MergeDataViolationError, } from "./common"; +import { detectEdition, W3Edition } from "./edition"; import { getNamesOfMergedMods } from "./mergeInventoryParsing"; import { getMergedModName, downloadScriptMerger } from "./scriptmerger"; import { IDeployedFile, IDeployment } from "./types"; @@ -202,6 +204,33 @@ function backupPath(profile: types.IProfile): string { ); } +// Merged scripts are compiled against a specific vanilla script set and text +// encoding, so a backup taken on one edition must not be restored onto another. +const EDITION_MARKER = ".w3edition"; + +async function writeEditionMarker(profilePath: string, edition: W3Edition): Promise { + try { + await writeFile(path.join(profilePath, EDITION_MARKER), edition, { encoding: "utf8" }); + } catch (err) { + log("warn", "failed to record edition alongside merged scripts", err); + } +} + +export async function isBackupCompatible( + profilePath: string, + edition: W3Edition, +): Promise { + try { + const marker = await readFile(path.join(profilePath, EDITION_MARKER), { encoding: "utf8" }); + return marker.trim() === edition; + } catch (err) { + // Backups taken before this marker existed have no way to say which + // edition they came from, and they're all pre-remaster, so restoring them + // has to stay the default. + return true; + } +} + async function handleMergedScripts(props: IBaseProps, opType: OpType, dest?: string) { const { scriptMergerTool, profile, gamePath } = props; if (!scriptMergerTool?.path) { @@ -229,14 +258,45 @@ async function handleMergedScripts(props: IBaseProps, opType: OpType, dest?: str path.basename(loarOrderFilepath), ); await moveFiles(mergedScriptsPath, path.join(profilePath, mergedModName), props); + await writeEditionMarker(profilePath, detectEdition(gamePath)); } else if (opType === "import") { - await moveFile(profilePath, mergerToolDir, MERGE_INV_MANIFEST); + // Load order is edition independent, so it's always restored. await moveFile( profilePath, path.dirname(loarOrderFilepath), path.basename(loarOrderFilepath), ); - await moveFiles(path.join(profilePath, mergedModName), mergedScriptsPath, props); + if (await isBackupCompatible(profilePath, detectEdition(gamePath))) { + // The inventory describes the merged scripts, so it only comes back + // with them. + await moveFile(profilePath, mergerToolDir, MERGE_INV_MANIFEST); + await moveFiles(path.join(profilePath, mergedModName), mergedScriptsPath, props); + } else { + props.api.sendNotification({ + type: "warning", + message: "Merged scripts were not restored", + // Not suppressible: silently skipping the restore leaves the user + // with a merge set that doesn't match their install. + allowSuppress: false, + actions: [ + { + title: "More", + action: () => + props.api.showDialog( + "info", + "Merged scripts not restored", + { + text: + "The merged scripts saved for this profile were created for a different " + + "edition of the game. Restoring them could break script compilation, so " + + "they have been left alone. Run the Script Merger again to rebuild them.", + }, + [{ label: "Close" }], + ), + }, + ], + }); + } } return Promise.resolve(); } catch (err) { @@ -343,7 +403,7 @@ export async function exportScriptMerges( const exportMergedData = async () => { try { - const tempPath = path.join(W3_TEMP_DATA_DIR, generate()); + const tempPath = path.join(getW3TempDataDir(), generate()); await fs.ensureDirWritableAsync(tempPath); await handleMergedScripts(props, "export", tempPath); const data = await prepareFileData(tempPath); @@ -426,7 +486,7 @@ export async function importScriptMerges( return Promise.reject(new util.UserCanceled()); } try { - const tempPath = path.join(W3_TEMP_DATA_DIR, generate()); + const tempPath = path.join(getW3TempDataDir(), generate()); await fs.ensureDirWritableAsync(tempPath); const data = await restoreFileData(fileData, tempPath); await handleMergedScripts(props, "import", tempPath); diff --git a/extensions/games/game-witcher3/src/modSettingsPriority.test.ts b/extensions/games/game-witcher3/src/modSettingsPriority.test.ts new file mode 100644 index 0000000000..fbeadfda0e --- /dev/null +++ b/extensions/games/game-witcher3/src/modSettingsPriority.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { assignPriorities } from "./modSettingsPriority"; + +const priorities = (result: ReturnType) => + result.map((entry) => entry.priority); + +const names = (result: ReturnType) => result.map((entry) => entry.name); + +describe("assignPriorities", () => { + it("gives every mod a distinct priority when none are in the load order", () => { + // A purge removes mods.settings, so the next deploy sees an empty load + // order and has to place every mod at once. + const result = assignPriorities({ + mods: ["modA", "modB", "modC", "modD"], + locked: [], + loadOrderIds: [], + }); + + expect(priorities(result)).toEqual([1, 2, 3, 4]); + expect(new Set(priorities(result)).size).toBe(result.length); + }); + + it("keeps the order the user arranged", () => { + const result = assignPriorities({ + mods: ["modA", "modB", "modC"], + locked: [], + loadOrderIds: ["modC", "modA", "modB"], + }); + + expect(names(result)).toEqual(["modC", "modA", "modB"]); + expect(priorities(result)).toEqual([1, 2, 3]); + }); + + it("pins locked mods to the top in their own order", () => { + const result = assignPriorities({ + mods: ["modA", "mod0000_MergedFiles", "modB"], + locked: ["mod0000_MergedFiles"], + loadOrderIds: ["modB", "modA"], + }); + + expect(names(result)).toEqual(["mod0000_MergedFiles", "modB", "modA"]); + expect(priorities(result)).toEqual([1, 2, 3]); + }); + + it("appends mods the load order does not know about, without colliding", () => { + const result = assignPriorities({ + mods: ["modA", "modNew1", "modB", "modNew2"], + locked: [], + loadOrderIds: ["modA", "modB"], + }); + + expect(names(result)).toEqual(["modA", "modB", "modNew1", "modNew2"]); + expect(new Set(priorities(result)).size).toBe(4); + }); + + it("never collides a load order entry with the locked range", () => { + const result = assignPriorities({ + mods: ["lockedOne", "lockedTwo", "modA", "modB"], + locked: ["lockedOne", "lockedTwo"], + loadOrderIds: ["modA", "modB"], + }); + + expect(priorities(result)).toEqual([1, 2, 3, 4]); + }); + + it("drops dlc folders, which are mounted from the DLC directory", () => { + const result = assignPriorities({ + mods: ["modA", "dlcSomething", "modB"], + locked: [], + loadOrderIds: [], + }); + + expect(names(result)).toEqual(["modA", "modB"]); + }); + + it("carries the mod id through as the key", () => { + const result = assignPriorities({ + mods: [{ name: "modA", id: "modA-1.0" }, "modManual"], + locked: [], + loadOrderIds: [], + }); + + expect(result).toEqual([ + { name: "modA", key: "modA-1.0", priority: 1 }, + { name: "modManual", key: "modManual", priority: 2 }, + ]); + }); + + it("keeps one entry per folder name", () => { + // The gathered list concatenates merged, managed and manual mods, which can + // name the same folder more than once. + const result = assignPriorities({ + mods: ["modA", { name: "modA", id: "modA-1.0" }, "modB"], + locked: [], + loadOrderIds: [], + }); + + expect(names(result)).toEqual(["modA", "modB"]); + expect(priorities(result)).toEqual([1, 2]); + }); +}); diff --git a/extensions/games/game-witcher3/src/modSettingsPriority.ts b/extensions/games/game-witcher3/src/modSettingsPriority.ts new file mode 100644 index 0000000000..c10470b396 --- /dev/null +++ b/extensions/games/game-witcher3/src/modSettingsPriority.ts @@ -0,0 +1,80 @@ +/** + * Priority assignment for mods.settings, kept free of imports so it can be unit + * tested without the Vortex API. + * + * The game orders mod folders by the Priority value, so two mods sharing one + * number leaves their relative order down to however the file happens to be + * enumerated. Priorities therefore have to be unique across the whole file. + */ + +/** A mod as it appears in the ini: either a bare folder name or a managed mod. */ +export type ModSettingsEntry = string | { name: string; id: string }; + +export interface IPriorityInput { + /** Every mod that should appear in the file, in the order they were gathered. */ + mods: ModSettingsEntry[]; + /** Folder names pinned to the top, in the order they should hold. */ + locked: string[]; + /** Folder names in the order the user arranged them. */ + loadOrderIds: string[]; +} + +export interface IPriorityResult { + name: string; + key: string; + priority: number; +} + +export const nameOf = (mod: ModSettingsEntry): string => + typeof mod === "object" && mod !== null ? mod.name : mod; + +const keyOf = (mod: ModSettingsEntry): string => + typeof mod === "object" && mod !== null ? mod.id : mod; + +/** + * Locked mods keep the top of the range in their given order, mods the user has + * arranged follow in that order, and anything the load order doesn't know about + * is appended. Every mod gets a distinct priority. + * + * Mods whose folder name begins with `dlc` are dropped: they are mounted from + * the DLC directory and the game rejects them here. + */ +export function assignPriorities(input: IPriorityInput): IPriorityResult[] { + const { mods, locked, loadOrderIds } = input; + + const relevant = mods.filter((mod) => !nameOf(mod).toLowerCase().startsWith("dlc")); + + const rank = (name: string): number => { + const lockedAt = locked.indexOf(name); + if (lockedAt !== -1) { + return lockedAt; + } + const orderedAt = loadOrderIds.indexOf(name); + if (orderedAt !== -1) { + return locked.length + orderedAt; + } + // Unknown to the load order: appended, never collapsed onto one priority. + return locked.length + loadOrderIds.length; + }; + + const seen = new Set(); + const deduped = relevant.filter((mod) => { + const name = nameOf(mod); + if (seen.has(name)) { + return false; + } + seen.add(name); + return true; + }); + + // A stable sort keeps the gathered order within each rank, so the unknown + // bucket stays in a predictable sequence. + return deduped + .map((mod, idx) => ({ mod, idx, rank: rank(nameOf(mod)) })) + .sort((lhs, rhs) => lhs.rank - rhs.rank || lhs.idx - rhs.idx) + .map((item, position) => ({ + name: nameOf(item.mod), + key: keyOf(item.mod), + priority: position + 1, + })); +} diff --git a/extensions/games/game-witcher3/src/priorityManager.ts b/extensions/games/game-witcher3/src/priorityManager.ts index be8cbadc16..adfd0cf808 100644 --- a/extensions/games/game-witcher3/src/priorityManager.ts +++ b/extensions/games/game-witcher3/src/priorityManager.ts @@ -2,6 +2,7 @@ import { selectors, types } from "@nexusmods/vortex-api"; import { GAME_ID } from "./common"; +import { maxPriorityFrom } from "./loadOrderPriority"; import { getPersistentLoadOrder } from "./migrations"; export type PriorityType = "position-based" | "prefix-based"; @@ -91,18 +92,6 @@ export class PriorityManager { }; public getMaxPriority = (props: IProps) => { - const { loadOrder, minPriority } = props; - return Object.keys(loadOrder).reduce((prev, key) => { - const prefixVal = loadOrder[key]?.data?.prefix ?? loadOrder[key]?.prefix; - const intVal = - prefixVal !== undefined ? parseInt(loadOrder[key].prefix, 10) : loadOrder[key].pos; - const posVal = loadOrder[key].pos; - if (posVal !== intVal) { - prev = intVal > prev ? intVal : prev; - } else { - prev = posVal > prev ? posVal : prev; - } - return prev; - }, minPriority); + return maxPriorityFrom(props.loadOrder, props.minPriority); }; } From cad31267f4f97d61ba7e84aefb7be213f08370cf Mon Sep 17 00:00:00 2001 From: IDCs Date: Mon, 28 Sep 2026 10:27:45 +0100 Subject: [PATCH 05/10] Fix the load order page crashing on mod changes The row helpers were plain functions called from JSX, so their hooks belonged to ItemRenderer and ran behind conditionals. A row that gained or lost an entry changed the hook count and React threw, taking the page down. Making them components keeps their hooks out of ItemRenderer's list. --- .../game-witcher3/src/views/ItemRenderer.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/extensions/games/game-witcher3/src/views/ItemRenderer.tsx b/extensions/games/game-witcher3/src/views/ItemRenderer.tsx index b4a996e903..7d8e9a82dc 100644 --- a/extensions/games/game-witcher3/src/views/ItemRenderer.tsx +++ b/extensions/games/game-witcher3/src/views/ItemRenderer.tsx @@ -35,9 +35,8 @@ interface IBaseProps { type IProps = IBaseProps & IConnectedProps & IActionProps; export function ItemRenderer(props: IBaseProps) { - if (props?.item?.loEntry === undefined) { - return null; - } + // Every hook runs before the guard, and the row body is a component rather + // than a called function, so this hook count never varies. const stateProps = useSelector(mapStateToProps); const dispatch = useDispatch(); const onSetLoadOrder = React.useCallback( @@ -46,7 +45,11 @@ export function ItemRenderer(props: IBaseProps) { }, [dispatch, stateProps.profile.id, stateProps.loadOrder], ); - return renderDraggable({ ...props, ...stateProps, onSetLoadOrder }); + + if (props?.item?.loEntry === undefined) { + return null; + } + return ; } function renderValidationError(props: IProps): JSX.Element { @@ -64,11 +67,8 @@ function renderValidationError(props: IProps): JSX.Element { ) : null; } -function renderViewModIcon(props: IProps): JSX.Element { +function ViewModIcon(props: IProps): JSX.Element { const { item, mods } = props; - if (isExternal(item.loEntry) || item.loEntry.modId === item.loEntry.name) { - return null; - } const context = React.useContext(MainContext); const [t] = useTranslation(I18N_NAMESPACE); const onClick = React.useCallback(() => { @@ -81,7 +81,9 @@ function renderViewModIcon(props: IProps): JSX.Element { util.batchDispatch(context.api.store.dispatch, batched); context.api.events.emit("show-main-page", "Mods"); }, [item, mods, context]); - return item.loEntry.modId !== undefined ? ( + + const unmanaged = isExternal(item.loEntry) || item.loEntry.modId === item.loEntry.name; + return !unmanaged && item.loEntry.modId !== undefined ? ( {key}

{renderExternalBanner(item.loEntry)} - {renderViewModIcon(props)} + {checkBox()} {lock()} From 3100d4e2aa543817d4fd077fbd827dc1338e5785 Mon Sep 17 00:00:00 2001 From: IDCs Date: Mon, 28 Sep 2026 13:50:16 +0100 Subject: [PATCH 06/10] Install the newest script merger by release tag Merger releases are no longer named as versions, so builds that read the name stay on 0.6.5 instead of offering a release they cannot verify. The version now comes from the tag, and the archive is picked by name. Drops the MD5 check and MD5Cache.json, so a new merger release reaches users without a Vortex release. A download that is incomplete, fails to extract or has no executable points the user at the manual install. part of LAZ-1011 --- .../games/game-witcher3/assets/MD5Cache.json | 7 - extensions/games/game-witcher3/package.json | 4 + extensions/games/game-witcher3/src/common.ts | 44 +------ .../game-witcher3/src/mergerRelease.test.ts | 92 +++++++++++++ .../games/game-witcher3/src/mergerRelease.ts | 67 ++++++++++ .../games/game-witcher3/src/scriptmerger.ts | 122 ++++++------------ pnpm-lock.yaml | 7 + 7 files changed, 207 insertions(+), 136 deletions(-) delete mode 100644 extensions/games/game-witcher3/assets/MD5Cache.json create mode 100644 extensions/games/game-witcher3/src/mergerRelease.test.ts create mode 100644 extensions/games/game-witcher3/src/mergerRelease.ts diff --git a/extensions/games/game-witcher3/assets/MD5Cache.json b/extensions/games/game-witcher3/assets/MD5Cache.json deleted file mode 100644 index 3804ecefa0..0000000000 --- a/extensions/games/game-witcher3/assets/MD5Cache.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "version": "0.6.5", - "archiveChecksum": "77D57B2384172604E8D859E8BE4F7DF9", - "execChecksum": "0C2AFAA49E83C680F89F891237F46E5D" - } -] diff --git a/extensions/games/game-witcher3/package.json b/extensions/games/game-witcher3/package.json index 10bf7d8c0e..7b85806345 100644 --- a/extensions/games/game-witcher3/package.json +++ b/extensions/games/game-witcher3/package.json @@ -14,8 +14,12 @@ "config": { "game": "The Witcher 3" }, + "dependencies": { + "semver": "catalog:" + }, "devDependencies": { "@nexusmods/vortex-api": "workspace:*", + "@types/semver": "catalog:", "@vortex/extension-test-mocks": "workspace:*" }, "nx": { diff --git a/extensions/games/game-witcher3/src/common.ts b/extensions/games/game-witcher3/src/common.ts index 7dc5666593..f9def1ad56 100644 --- a/extensions/games/game-witcher3/src/common.ts +++ b/extensions/games/game-witcher3/src/common.ts @@ -1,23 +1,6 @@ -import crypto from "crypto"; import path from "path"; -import { fs, util } from "@nexusmods/vortex-api"; -export class MD5ComparisonError extends Error { - private mPath; - constructor(message, file) { - super(message); - this.mPath = file; - } - - get affectedFile() { - return this.mPath; - } - - get errorMessage() { - return this.message + ": " + this.mPath; - } -} - +import { util } from "@nexusmods/vortex-api"; export class ResourceInaccessibleError extends Error { private mIsReportingAllowed; private mFilePath; @@ -81,31 +64,6 @@ export class MergeDataViolationError extends Error { } } -export function calcHashImpl(filePath) { - return new Promise((resolve, reject) => { - const hash = crypto.createHash("md5"); - const stream = fs.createReadStream(filePath); - stream.on("readable", () => { - const data = stream.read(); - if (data) { - hash.update(data); - } - }); - stream.on("end", () => resolve(hash.digest("hex"))); - stream.on("error", reject); - }); -} - -export function getHash(filePath, tries = 3) { - return calcHashImpl(filePath).catch((err) => { - if (["EMFILE", "EBADF"].includes(err["code"]) && tries > 0) { - return getHash(filePath, tries - 1); - } else { - return Promise.reject(err); - } - }); -} - export function getLoadOrderFilePath() { return path.join(util.getVortexPath("documents"), "The Witcher 3", LOAD_ORDER_FILENAME); } diff --git a/extensions/games/game-witcher3/src/mergerRelease.test.ts b/extensions/games/game-witcher3/src/mergerRelease.test.ts new file mode 100644 index 0000000000..04a66de126 --- /dev/null +++ b/extensions/games/game-witcher3/src/mergerRelease.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + type IGithubRelease, + type IMergerRelease, + latestMergerRelease, + mergerDownloadProblem, +} from "./mergerRelease"; + +const DOWNLOAD_ROOT = "https://github.com/IDCs/WitcherScriptMerger/releases/download"; +const ARCHIVE_SIZE = 100_728_223; + +function release(tag: string, overrides: Partial = {}): IGithubRelease { + const version = tag.replace(/^v/, ""); + const name = `WitcherScriptMerger-${version}.7z`; + return { + tag_name: tag, + name: `WitcherScriptMerger ${version}`, + prerelease: false, + assets: [{ name, size: ARCHIVE_SIZE, browser_download_url: `${DOWNLOAD_ROOT}/${tag}/${name}` }], + ...overrides, + }; +} + +describe("latestMergerRelease", () => { + it("picks the newest release by tag regardless of listing order", () => { + const picked = latestMergerRelease([release("v0.6.5"), release("v0.7.0"), release("v0.6.6")]); + + expect(picked).toEqual({ + version: "0.7.0", + fileName: "WitcherScriptMerger-0.7.0.7z", + downloadLink: `${DOWNLOAD_ROOT}/v0.7.0/WitcherScriptMerger-0.7.0.7z`, + size: ARCHIVE_SIZE, + }); + }); + + it("reads the version from the tag, not the release name", () => { + // Release names are not versions, so older Vortex builds skip them. + expect(latestMergerRelease([release("v0.7.0", { name: "anything" })])?.version).toBe("0.7.0"); + }); + + it("finds the archive by name rather than position", () => { + const withNotes = release("v0.7.0"); + withNotes.assets.unshift({ name: "notes.txt", size: 1, browser_download_url: "notes" }); + + expect(latestMergerRelease([withNotes])?.fileName).toBe("WitcherScriptMerger-0.7.0.7z"); + }); + + it("installs only releases that are versioned, published and at least 0.6.5", () => { + const picked = latestMergerRelease([ + release("v0.6.5"), + release("tools-1.0.0"), + release("v0.8.0", { prerelease: true }), + release("v0.6.4"), + release("v0.9.0", { assets: [] }), + ]); + + expect(picked?.version).toBe("0.6.5"); + }); + + it("installs only plain major.minor.patch tags", () => { + // The prerelease flag is set by hand, so the tag has to carry the rule too. + const picked = latestMergerRelease([ + release("v0.6.5"), + release("v0.7.0-rc.1"), + release("v0.8.0+build.1"), + ]); + + expect(picked?.version).toBe("0.6.5"); + }); + + it("returns nothing when no release qualifies", () => { + expect(latestMergerRelease([])).toBeUndefined(); + expect(latestMergerRelease([release("tools-1.0.0")])).toBeUndefined(); + }); +}); + +describe("mergerDownloadProblem", () => { + const picked = latestMergerRelease([release("v0.7.0")]) as IMergerRelease; + + it("accepts a complete download", () => { + expect(mergerDownloadProblem(200, ARCHIVE_SIZE, picked)).toBeUndefined(); + }); + + it("rejects a response that is not a successful download", () => { + expect(mergerDownloadProblem(404, ARCHIVE_SIZE, picked)).toMatch(/404/); + }); + + it("rejects a download whose size differs from the published asset", () => { + expect(mergerDownloadProblem(200, ARCHIVE_SIZE - 1, picked)).toMatch(/size/); + }); +}); diff --git a/extensions/games/game-witcher3/src/mergerRelease.ts b/extensions/games/game-witcher3/src/mergerRelease.ts new file mode 100644 index 0000000000..1acec82335 --- /dev/null +++ b/extensions/games/game-witcher3/src/mergerRelease.ts @@ -0,0 +1,67 @@ +import semver from "semver"; + +/** Oldest merger release Vortex installs. */ +const RELEASE_CUTOFF = "0.6.5"; + +/** A release as the GitHub releases API lists it, trimmed to the fields used here. */ +export interface IGithubRelease { + tag_name: string; + name: string; + prerelease: boolean; + assets: Array<{ name: string; size: number; browser_download_url: string }>; +} + +export interface IMergerRelease { + version: string; + fileName: string; + downloadLink: string; + size: number; +} + +/** Tags as ReleaseContract publishes them; anything else is not a merger release. */ +const RELEASE_TAG = /^v(\d+\.\d+\.\d+)$/; + +/** Archive name, as ReleaseContract in WitcherScriptMerger.ReleaseTools publishes it. */ +function archiveName(version: string): string { + return `WitcherScriptMerger-${version}.7z`; +} + +/** + * The newest installable merger release. The version comes from the tag: + * release names are deliberately not versions, because older Vortex builds + * read the name and cannot install anything newer than 0.6.5. + */ +export function latestMergerRelease(releases: IGithubRelease[]): IMergerRelease | undefined { + let latest: IMergerRelease | undefined; + for (const release of releases) { + const version = semver.valid(RELEASE_TAG.exec(release.tag_name)?.[1] ?? null); + if (release.prerelease || version === null || semver.lt(version, RELEASE_CUTOFF)) { + continue; + } + const asset = release.assets.find((candidate) => candidate.name === archiveName(version)); + if (asset !== undefined && (latest === undefined || semver.gt(version, latest.version))) { + latest = { + version, + fileName: asset.name, + downloadLink: asset.browser_download_url, + size: asset.size, + }; + } + } + return latest; +} + +/** Why a finished download can't be installed, or undefined when it can. */ +export function mergerDownloadProblem( + statusCode: number | undefined, + receivedBytes: number, + release: IMergerRelease, +): string | undefined { + if (statusCode !== 200) { + return `download failed with status ${statusCode}`; + } + if (receivedBytes !== release.size) { + return `download size ${receivedBytes} does not match the published ${release.size}`; + } + return undefined; +} diff --git a/extensions/games/game-witcher3/src/scriptmerger.ts b/extensions/games/game-witcher3/src/scriptmerger.ts index 972aceacc8..1aad8b821d 100644 --- a/extensions/games/game-witcher3/src/scriptmerger.ts +++ b/extensions/games/game-witcher3/src/scriptmerger.ts @@ -10,15 +10,15 @@ import semver from "semver"; import { Builder, parseStringPromise } from "xml2js"; import { isMergerToolValidForRoot, mergerDirForRoot } from "./mergerPaths"; +import { latestMergerRelease, mergerDownloadProblem } from "./mergerRelease"; import { IIncomingGithubHttpHeaders } from "./types"; -import { shouldNotifyMissingScriptMerger } from "./util"; +import { fileExists, shouldNotifyMissingScriptMerger } from "./util"; -const RELEASE_CUTOFF = "0.6.5"; const GITHUB_URL = "https://api.github.com/repos/IDCs/WitcherScriptMerger"; const MERGER_CONFIG_FILE = "WitcherScriptMerger.exe.config"; -const { getHash, MD5ComparisonError, SCRIPT_MERGER_ID } = require("./common"); +const { SCRIPT_MERGER_FILES, SCRIPT_MERGER_ID } = require("./common"); function query(baseUrl, request) { return new Promise((resolve, reject) => { @@ -132,71 +132,21 @@ async function getMergerVersion(api: types.IExtensionApi) { } } -let _HASH_CACHE; -async function getCache(api: types.IExtensionApi) { - if (_HASH_CACHE === undefined) { - try { - const data = await fs.readFileAsync(path.join(__dirname, "MD5Cache.json"), { - encoding: "utf8", - }); - _HASH_CACHE = JSON.parse(data); - } catch (err) { - // If this ever happens - the user's machine must be screwed. - // Maybe virus ? defective hardware ? did he manually manipulate - // the file ? - api.showErrorNotification("Failed to parse MD5Cache", err); - return (_HASH_CACHE = []); - } - } - - return _HASH_CACHE; -} +/** A downloaded merger that can't be installed; the user is sent to the manual install. */ +class UnusableMergerDownloadError extends Error {} async function onDownloadComplete(api, archivePath, mostRecentVersion) { - return new Promise(async (resolve, reject) => { - let archiveHash; - try { - archiveHash = await getHash(archivePath); - } catch (err) { - return Promise.reject(new MD5ComparisonError("Failed to calculate hash", archivePath)); - } - const hashCache = await getCache(api); - if ( - hashCache.find( - (entry) => - entry.archiveChecksum.toLowerCase() === archiveHash && - entry.version === mostRecentVersion, - ) === undefined - ) { - // Not a valid hash - something may have happened during the download ? - return reject(new MD5ComparisonError("Corrupted archive download", archivePath)); - } - - return resolve(archivePath); - }) - .then((archivePath) => extractScriptMerger(api, archivePath)) - .then(async (mergerPath) => { - const mergerExec = path.join(mergerPath, "WitcherScriptMerger.exe"); - let execHash; - try { - execHash = await getHash(mergerExec); - } catch (err) { - return Promise.reject(new MD5ComparisonError("Failed to calculate hash", mergerExec)); - } - const hashCache = await getCache(api); - if ( - hashCache.find( - (entry) => - entry.execChecksum.toLowerCase() === execHash && entry.version === mostRecentVersion, - ) === undefined - ) { - // Not a valid hash - something may have happened during extraction ? - return Promise.reject(new MD5ComparisonError("Corrupted executable", mergerExec)); - } - - return Promise.resolve(mergerPath); - }) - .then((mergerPath) => setUpMerger(api, mostRecentVersion, mergerPath)); + let mergerPath; + try { + mergerPath = await extractScriptMerger(api, archivePath); + } catch (err) { + log("error", "failed to extract script merger", err); + throw new UnusableMergerDownloadError("extraction failed"); + } + if (!(await fileExists(path.join(mergerPath, SCRIPT_MERGER_FILES[0])))) { + throw new UnusableMergerDownloadError(`archive has no ${SCRIPT_MERGER_FILES[0]}`); + } + return setUpMerger(api, mostRecentVersion, mergerPath); } export async function getScriptMergerDir(api, create = false) { @@ -248,20 +198,17 @@ export async function downloadScriptMerger(api: types.IExtensionApi) { if (!Array.isArray(releases)) { return Promise.reject(new util.DataInvalid("expected array of github releases")); } - const current = releases - .filter((rel) => semver.valid(rel.name) && semver.gte(rel.name, RELEASE_CUTOFF)) - .sort((lhs, rhs) => semver.compare(rhs.name, lhs.name)); + const latest = latestMergerRelease(releases); + if (latest === undefined) { + return Promise.reject(new util.DataInvalid("no installable script merger release")); + } - return Promise.resolve(current); + return Promise.resolve(latest); }) - .then(async (currentRelease) => { - mostRecentVersion = currentRelease[0].name; - const fileName = currentRelease[0].assets[0].name; - const downloadLink = currentRelease[0].assets[0].browser_download_url; - if ( - !!currentlyInstalledVersion && - semver.gte(currentlyInstalledVersion, currentRelease[0].name) - ) { + .then(async (latest) => { + const { version, fileName, downloadLink } = latest; + mostRecentVersion = version; + if (!!currentlyInstalledVersion && semver.gte(currentlyInstalledVersion, version)) { return Promise.reject(new util.ProcessCanceled("Already up to date")); } @@ -319,6 +266,10 @@ export async function downloadScriptMerger(api: types.IExtensionApi) { } }) .on("end", () => { + const problem = mergerDownloadProblem(res.statusCode, output.length, latest); + if (problem !== undefined) { + return reject(new UnusableMergerDownloadError(problem)); + } api.sendNotification({ ...downloadNotif, progress: 100, @@ -358,12 +309,11 @@ export async function downloadScriptMerger(api: types.IExtensionApi) { .catch((err) => { api.dismissNotification(extractNotifId); api.dismissNotification(downloadNotifId); - if (err instanceof MD5ComparisonError || err instanceof util.ProcessCanceled) { - log( - "error", - "Failed to automatically install Script Merger", - err.errorMessage, - ); + if ( + err instanceof UnusableMergerDownloadError || + err instanceof util.ProcessCanceled + ) { + log("error", "Failed to automatically install Script Merger", err.message); api.sendNotification({ type: "error", message: api.translate("Please install Script Merger manually", { @@ -407,7 +357,7 @@ export async function downloadScriptMerger(api: types.IExtensionApi) { .then((archivePath) => onDownloadComplete(api, archivePath, mostRecentVersion)) .catch(async (err) => { const raiseManualInstallNotif = () => { - log("error", "Failed to automatically install Script Merger", err.errorMessage); + log("error", "Failed to automatically install Script Merger", err.message); if (!shouldNotifyMissingScriptMerger(api)) { // Nothing to chase on the remaster - mods there merge themselves. return; @@ -426,7 +376,7 @@ export async function downloadScriptMerger(api: types.IExtensionApi) { }; api.dismissNotification(extractNotifId); api.dismissNotification(downloadNotifId); - if (err instanceof MD5ComparisonError) { + if (err instanceof UnusableMergerDownloadError) { raiseManualInstallNotif(); return Promise.resolve(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d967de3e38..ec0fc9daa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2589,10 +2589,17 @@ importers: version: link:../../../packages/vortex-api extensions/games/game-witcher3: + dependencies: + semver: + specifier: 'catalog:' + version: 7.7.4 devDependencies: '@nexusmods/vortex-api': specifier: workspace:* version: link:../../../packages/vortex-api + '@types/semver': + specifier: 'catalog:' + version: 7.7.1 '@vortex/extension-test-mocks': specifier: workspace:* version: link:../../../packages/extension-test-mocks From 38bb0800514a57b1ecd7a7a0c0b22feb57d438d8 Mon Sep 17 00:00:00 2001 From: IDCs Date: Mon, 28 Sep 2026 14:28:59 +0100 Subject: [PATCH 07/10] Build the Witcher 3 test paths with the host's separators CI also runs the suite on Linux, where a backslash is not a path separator, so fixtures written with hardcoded backslashes fail there. part of LAZ-1011 --- .../game-witcher3/src/installers.test.ts | 20 +++++++----- .../game-witcher3/src/mergerPaths.test.ts | 32 ++++++++++++------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/extensions/games/game-witcher3/src/installers.test.ts b/extensions/games/game-witcher3/src/installers.test.ts index c01a2d1506..055fc18e3d 100644 --- a/extensions/games/game-witcher3/src/installers.test.ts +++ b/extensions/games/game-witcher3/src/installers.test.ts @@ -6,35 +6,39 @@ import { GAME_ID } from "./common"; import { MAX_MOD_NAME_LENGTH } from "./edition"; import { installContent, testSupportedContent } from "./installers"; +// Built with the host's separators, as the file lists Vortex passes in are. +const SCRIPT = path.join("content", "scripts", "x.ws"); +const STAGING = path.join(path.parse(process.cwd()).root, "staging"); + describe("testSupportedContent", () => { it("claims archives that are a bare content directory", async () => { - const result = await testSupportedContent(["content\\scripts\\x.ws"], GAME_ID); + const result = await testSupportedContent([SCRIPT], GAME_ID); expect(result.supported).toBeTruthy(); }); it("leaves archives with no top level content directory alone", async () => { // These belong to the mod/dlc installers; claiming them here routes the // files through the wrong destination logic. - const result = await testSupportedContent(["modFoo\\content\\scripts\\x.ws"], GAME_ID); + const result = await testSupportedContent([path.join("modFoo", SCRIPT)], GAME_ID); expect(result.supported).toBeFalsy(); }); it("ignores archives for other games", async () => { - const result = await testSupportedContent(["content\\scripts\\x.ws"], "skyrim"); + const result = await testSupportedContent([SCRIPT], "skyrim"); expect(result.supported).toBeFalsy(); }); }); describe("installContent", () => { - const DEST = "C:\\staging\\Some Mod-1234.installing"; + const DEST = path.join(STAGING, "Some Mod-1234.installing"); it("returns instructions in the shape the install manager expects", async () => { - const result = await installContent(["content\\scripts\\x.ws"], DEST); + const result = await installContent([SCRIPT], DEST); expect(Array.isArray(result.instructions)).toBe(true); }); it("names the mod folder after the archive rather than its staging path", async () => { - const result = await installContent(["content\\scripts\\x.ws"], DEST); + const result = await installContent([SCRIPT], DEST); expect(result.instructions[0].destination).toBe( path.join("modSomeMod-1234", "content", "scripts", "x.ws"), ); @@ -42,8 +46,8 @@ describe("installContent", () => { it("keeps the mod folder within the length the game accepts", async () => { const result = await installContent( - ["content\\x.ws"], - `C:\\staging\\${"A".repeat(120)}.installing`, + [path.join("content", "x.ws")], + path.join(STAGING, `${"A".repeat(120)}.installing`), ); const folder = result.instructions[0].destination.split(/[\\/]/)[0]; expect(folder.length).toBeLessThanOrEqual(MAX_MOD_NAME_LENGTH); diff --git a/extensions/games/game-witcher3/src/mergerPaths.test.ts b/extensions/games/game-witcher3/src/mergerPaths.test.ts index 365d012c73..b4296b70f5 100644 --- a/extensions/games/game-witcher3/src/mergerPaths.test.ts +++ b/extensions/games/game-witcher3/src/mergerPaths.test.ts @@ -1,30 +1,40 @@ +import path from "node:path"; + import { describe, expect, it } from "vitest"; import { isMergerToolValidForRoot, isPathWithinRoot, mergerDirForRoot } from "./mergerPaths"; -const GOG = "C:\\Games\\GOG Galaxy\\Games\\The Witcher 3 Wild Hunt GOTY"; -const STEAM = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\The Witcher 3"; +// Built with the host's separators, as the paths Vortex passes in are. +const FS_ROOT = path.parse(process.cwd()).root; +const GOG = path.join(FS_ROOT, "Games", "GOG Galaxy", "Games", "The Witcher 3 Wild Hunt GOTY"); +const STEAM = path.join( + FS_ROOT, + "Program Files (x86)", + "Steam", + "steamapps", + "common", + "The Witcher 3", +); +const MERGER_EXE = path.join("WitcherScriptMerger", "WitcherScriptMerger.exe"); describe("isMergerToolValidForRoot", () => { it("accepts a merger installed under the discovered game root", () => { - const tool = `${STEAM}\\WitcherScriptMerger\\WitcherScriptMerger.exe`; - expect(isMergerToolValidForRoot(tool, STEAM)).toBe(true); + expect(isMergerToolValidForRoot(path.join(STEAM, MERGER_EXE), STEAM)).toBe(true); }); it("rejects a merger belonging to the other install", () => { // Both editions are managed under one game entry, and the other install is // still on disk, so the stale path stats fine. - const tool = `${GOG}\\WitcherScriptMerger\\WitcherScriptMerger.exe`; - expect(isMergerToolValidForRoot(tool, STEAM)).toBe(false); + expect(isMergerToolValidForRoot(path.join(GOG, MERGER_EXE), STEAM)).toBe(false); }); it("ignores case and trailing separators", () => { - const tool = `${STEAM.toUpperCase()}\\witcherscriptmerger\\WitcherScriptMerger.exe`; - expect(isMergerToolValidForRoot(tool, `${STEAM}\\`)).toBe(true); + const tool = path.join(STEAM.toUpperCase(), MERGER_EXE.toLowerCase()); + expect(isMergerToolValidForRoot(tool, STEAM + path.sep)).toBe(true); }); it("does not treat a sibling directory with a shared prefix as inside the root", () => { - expect(isPathWithinRoot(`${STEAM} Remastered\\x.exe`, STEAM)).toBe(false); + expect(isPathWithinRoot(path.join(`${STEAM} Remastered`, "x.exe"), STEAM)).toBe(false); }); it("treats the root itself as within the root", () => { @@ -33,12 +43,12 @@ describe("isMergerToolValidForRoot", () => { it("rejects an undefined path or root", () => { expect(isMergerToolValidForRoot(undefined, STEAM)).toBe(false); - expect(isMergerToolValidForRoot(`${STEAM}\\x.exe`, undefined)).toBe(false); + expect(isMergerToolValidForRoot(path.join(STEAM, "x.exe"), undefined)).toBe(false); }); }); describe("mergerDirForRoot", () => { it("places the merger inside the game root", () => { - expect(mergerDirForRoot(STEAM)).toBe(`${STEAM}\\WitcherScriptMerger`); + expect(mergerDirForRoot(STEAM)).toBe(path.join(STEAM, "WitcherScriptMerger")); }); }); From 7020805bcffdc6d0ddcb6917d5fc640dfd510157 Mon Sep 17 00:00:00 2001 From: IDCs Date: Tue, 29 Sep 2026 05:36:46 +0100 Subject: [PATCH 08/10] Remove the unused load order priority machinery PriorityManager, its priority type setting and the per-entry prefix fed nothing: mods.settings priorities come from each entry's position in the load order. Entries still carry their position as data.prefix, because older versions of the extension share the same state and sort by it without a guard. part of LAZ-1011 --- extensions/games/game-witcher3/src/actions.ts | 2 - .../src/collectionLoadOrder.test.ts | 36 ++++--- .../game-witcher3/src/collectionLoadOrder.ts | 37 ++++--- .../game-witcher3/src/collections/types.ts | 5 - extensions/games/game-witcher3/src/common.ts | 4 - .../games/game-witcher3/src/deprecated.ts | 95 ------------------ .../games/game-witcher3/src/eventHandlers.ts | 51 ++-------- .../games/game-witcher3/src/iconbarActions.ts | 14 +-- extensions/games/game-witcher3/src/index.ts | 21 +--- .../games/game-witcher3/src/iniParser.ts | 15 +-- .../games/game-witcher3/src/loadOrder.tsx | 18 +--- .../src/loadOrderPriority.test.ts | 29 ------ .../game-witcher3/src/loadOrderPriority.ts | 29 ------ .../games/game-witcher3/src/migrations.ts | 8 +- .../game-witcher3/src/priorityManager.ts | 97 ------------------- .../games/game-witcher3/src/reducers.ts | 6 +- 16 files changed, 77 insertions(+), 390 deletions(-) delete mode 100644 extensions/games/game-witcher3/src/deprecated.ts delete mode 100644 extensions/games/game-witcher3/src/loadOrderPriority.test.ts delete mode 100644 extensions/games/game-witcher3/src/loadOrderPriority.ts delete mode 100644 extensions/games/game-witcher3/src/priorityManager.ts diff --git a/extensions/games/game-witcher3/src/actions.ts b/extensions/games/game-witcher3/src/actions.ts index 2c296cd97d..25f9bc129f 100644 --- a/extensions/games/game-witcher3/src/actions.ts +++ b/extensions/games/game-witcher3/src/actions.ts @@ -1,7 +1,5 @@ import { createAction } from "redux-act"; -export const setPriorityType = createAction("TW3_SET_PRIORITY_TYPE", (type) => type); - export const setSuppressModLimitPatch = createAction( "TW3_SET_SUPPRESS_LIMIT_PATCH", (suppress) => suppress, diff --git a/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts b/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts index a7e24fdc43..5640ebf4f3 100644 --- a/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts +++ b/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts @@ -1,14 +1,13 @@ import { types } from "@nexusmods/vortex-api"; import { describe, expect, it } from "vitest"; -import { genCollectionLoadOrder } from "./collectionLoadOrder"; +import { genCollectionLoadOrder, withPositionPrefix } from "./collectionLoadOrder"; -const entry = (modId: string, prefix: number): types.IFBLOLoadOrderEntry => ({ +const entry = (modId: string): types.IFBLOLoadOrderEntry => ({ id: modId, modId, name: modId, enabled: true, - data: { prefix }, }); const mod = (id: string, type = ""): types.IMod => ({ @@ -27,17 +26,15 @@ const modIds = (loadOrder: types.LoadOrder) => loadOrder.map((item) => item.modI describe("genCollectionLoadOrder", () => { it("keeps the load order in the order the entries arrive in", () => { - // data.prefix is only refreshed on deploy, so an entry moved on the load - // order page still carries the prefix it had in its old position. - const loadOrder = [entry("modA", 3), entry("modB", 1), entry("modC", 2)]; + const loadOrder = [entry("modC"), entry("modA"), entry("modB")]; const result = genCollectionLoadOrder(loadOrder, mods("modA", "modB", "modC")); - expect(modIds(result)).toEqual(["modA", "modB", "modC"]); + expect(modIds(result)).toEqual(["modC", "modA", "modB"]); }); it("keeps locked entries that have no corresponding mod", () => { - const loadOrder = [entry("mod0000____CompilationTrigger", 1), entry("modA", 2)]; + const loadOrder = [entry("mod0000____CompilationTrigger"), entry("modA")]; const result = genCollectionLoadOrder(loadOrder, mods("modA")); @@ -45,7 +42,7 @@ describe("genCollectionLoadOrder", () => { }); it("drops entries whose mod is not managed", () => { - const loadOrder = [entry("modA", 1), entry("modGone", 2), entry("modB", 3)]; + const loadOrder = [entry("modA"), entry("modGone"), entry("modB")]; const result = genCollectionLoadOrder(loadOrder, mods("modA", "modB")); @@ -53,7 +50,7 @@ describe("genCollectionLoadOrder", () => { }); it("drops collections", () => { - const loadOrder = [entry("modA", 1), entry("someCollection", 2)]; + const loadOrder = [entry("modA"), entry("someCollection")]; const available = mods("modA"); available.someCollection = mod("someCollection", "collection"); @@ -62,11 +59,22 @@ describe("genCollectionLoadOrder", () => { expect(modIds(result)).toEqual(["modA"]); }); - it("carries the prefix through untouched", () => { - const loadOrder = [entry("modA", 7)]; + it("numbers the exported entries by their position", () => { + const loadOrder = [entry("modC"), entry("modGone"), entry("modA")]; - const result = genCollectionLoadOrder(loadOrder, mods("modA")); + const result = genCollectionLoadOrder(loadOrder, mods("modA", "modC")); + + expect(result.map((item) => item.data.prefix)).toEqual([0, 1]); + }); +}); + +describe("withPositionPrefix", () => { + it("replaces any existing prefix with the entry's position and keeps other data", () => { + const result = withPositionPrefix([ + { ...entry("modA"), data: { prefix: "41", other: true } }, + entry("modB"), + ]); - expect(result[0].data.prefix).toBe(7); + expect(result.map((item) => item.data)).toEqual([{ prefix: 0, other: true }, { prefix: 1 }]); }); }); diff --git a/extensions/games/game-witcher3/src/collectionLoadOrder.ts b/extensions/games/game-witcher3/src/collectionLoadOrder.ts index 73f9b44c65..b05e707a12 100644 --- a/extensions/games/game-witcher3/src/collectionLoadOrder.ts +++ b/extensions/games/game-witcher3/src/collectionLoadOrder.ts @@ -16,6 +16,17 @@ export function isModInCollection(collectionMod: types.IMod, mod: types.IMod) { ); } +/** + * Stamps each entry's position as `data.prefix`. Nothing here reads it, but older + * versions of this extension share the same state and sort by it unguarded. + */ +export function withPositionPrefix(entries: T[]): T[] { + return entries.map((entry, position) => ({ + ...entry, + data: { ...entry.data, prefix: position }, + })); +} + /** * Reduces the profile's load order down to the entries a collection should * carry. The array order is the load order, so the entries are kept in the @@ -26,16 +37,18 @@ export function genCollectionLoadOrder( mods: Record, // key: mod id collection?: types.IMod, ): types.LoadOrder { - return loadOrder.filter((entry) => { - const modId = entry.modId; - if (modId === undefined) { - return false; - } - if (modId.includes(LOCKED_PREFIX)) { - return true; - } - return collection !== undefined - ? isValidMod(mods[modId]) && isModInCollection(collection, mods[modId]) - : isValidMod(mods[modId]); - }); + return withPositionPrefix( + loadOrder.filter((entry) => { + const modId = entry.modId; + if (modId === undefined) { + return false; + } + if (modId.includes(LOCKED_PREFIX)) { + return true; + } + return collection !== undefined + ? isValidMod(mods[modId]) && isModInCollection(collection, mods[modId]) + : isValidMod(mods[modId]); + }), + ); } diff --git a/extensions/games/game-witcher3/src/collections/types.ts b/extensions/games/game-witcher3/src/collections/types.ts index f3c523907f..401bc6edde 100644 --- a/extensions/games/game-witcher3/src/collections/types.ts +++ b/extensions/games/game-witcher3/src/collections/types.ts @@ -42,11 +42,6 @@ export interface ILoadOrderEntry { // Is this entry enabled ? enabled: boolean; - // The position of the entry is usually sufficient when displaying - // index/priority of most mods but in some cases it is more advantageous - // to use prefixes, particularly when a game loads mods alphabetically. - prefix?: string; - // custom data passed along with the load order entry data?: T; diff --git a/extensions/games/game-witcher3/src/common.ts b/extensions/games/game-witcher3/src/common.ts index f9def1ad56..606863078b 100644 --- a/extensions/games/game-witcher3/src/common.ts +++ b/extensions/games/game-witcher3/src/common.ts @@ -72,10 +72,6 @@ export function getDx12UserSettingsPath() { return path.join(util.getVortexPath("documents"), "The Witcher 3", DX12_USER_SETTINGS_FILENAME); } -export function getPriorityTypeBranch() { - return ["settings", "witcher3", "prioritytype"]; -} - export function getRemasterNoticeSeenBranch() { return ["settings", "witcher3", "remasterNoticeSeen"]; } diff --git a/extensions/games/game-witcher3/src/deprecated.ts b/extensions/games/game-witcher3/src/deprecated.ts deleted file mode 100644 index 8dafc36582..0000000000 --- a/extensions/games/game-witcher3/src/deprecated.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - FBLO does not currently support individual context actions - leaving this here for now as we may decide to enhance FBLO in - the future. -*/ -// const onSetPriority = (itemKey, wantedPriority) => { -// return this.writeToModSettings() -// .then(() => { -// wantedPriority = +wantedPriority; -// const state = context.api.store.getState(); -// const activeProfile = selectors.activeProfile(state); -// const modId = _INI_STRUCT[itemKey].VK; -// const loEntry = loadOrder[modId]; -// if (priorityManager.priorityType === 'position-based') { -// context.api.store.dispatch(actions.setLoadOrderEntry( -// activeProfile.id, modId, { -// ...loEntry, -// pos: (loEntry.pos < wantedPriority) ? wantedPriority : wantedPriority - 2, -// })); -// loadOrder = util.getSafe(state, ['persistent', 'loadOrder', activeProfile.id], {}); -// } else { -// context.api.store.dispatch(actions.setLoadOrderEntry( -// activeProfile.id, modId, { -// ...loEntry, -// prefix: parseInt(_INI_STRUCT[itemKey].Priority, 10), -// })); -// } -// if (refreshFunc !== undefined) { -// refreshFunc(); -// } -// }) -// .catch(err => modSettingsErrorHandler(context, err, -// 'Failed to modify load order file')); -// }; - -// import * as React from 'react'; -// import { withTranslation } from 'react-i18next'; -// import { connect } from 'react-redux'; -// import { ComponentEx, ToolbarIcon, types, util } from 'vortex-api'; - -// import { setPriorityType } from '../actions'; -// import { getPriorityTypeBranch } from '../common'; -// import { PriorityType } from '../priorityManager'; - -// interface IConnectedProps { -// priorityType: PriorityType; -// } - -// interface IActionProps { -// onSetPriorityType: (type: string) => void; -// } - -// type IProps = IConnectedProps & IActionProps; -// const TBI = ToolbarIcon as any; -// class PriorityTypeButton extends ComponentEx { -// public render(): JSX.Element { -// const { t, priorityType } = this.props; - -// return ( -// -// ); -// } - -// private switch = () => { -// const current = this.props.priorityType; -// this.props.onSetPriorityType((current === 'position-based') -// ? 'prefix-based' : 'position-based'); -// } -// } - -// function mapStateToProps(state: types.IState, ownProps: IProps): IConnectedProps { -// return { -// priorityType: util.getSafe(state, getPriorityTypeBranch(), 'prefix-based'), -// }; -// } - -// function mapDispatchToProps(dispatch: any): IActionProps { -// return { -// onSetPriorityType: (type: string) => dispatch(setPriorityType(type)), -// }; -// } - -// export default -// withTranslation(['common', 'witcher3'])( -// connect(mapStateToProps, mapDispatchToProps)(PriorityTypeButton as any), -// ) as React.ComponentClass<{}>; diff --git a/extensions/games/game-witcher3/src/eventHandlers.ts b/extensions/games/game-witcher3/src/eventHandlers.ts index 3d0a0f8b16..de0dfa252b 100644 --- a/extensions/games/game-witcher3/src/eventHandlers.ts +++ b/extensions/games/game-witcher3/src/eventHandlers.ts @@ -1,10 +1,9 @@ /* eslint-disable */ import { actions, types, selectors, util } from "@nexusmods/vortex-api"; -import { setPriorityType, setRemasterNoticeSeen } from "./actions"; +import { setRemasterNoticeSeen } from "./actions"; import { GAME_ID, - getPriorityTypeBranch, getRemasterNoticeSeenBranch, PART_SUFFIX, INPUT_XML_FILENAME, @@ -16,7 +15,6 @@ import IniStructure from "./iniParser"; import * as menuMod from "./menumod"; import { storeToProfile, restoreFromProfile } from "./mergeBackup"; import { getPersistentLoadOrder } from "./migrations"; -import { PriorityManager } from "./priorityManager"; import { IRemoveModOptions } from "./types"; import { validateProfile, @@ -91,8 +89,6 @@ export function onGameModeActivation(api: types.IExtensionApi) { const state = api.getState(); const lastProfId = selectors.lastActiveProfileForGame(state, gameMode); const activeProf = selectors.activeProfile(state); - const priorityType = util.getSafe(state, getPriorityTypeBranch(), "prefix-based"); - api.store.dispatch(setPriorityType(priorityType)); notifyRemasterOnce(api); if (lastProfId !== activeProf?.id) { try { @@ -119,45 +115,35 @@ export const onWillDeploy = (api: types.IExtensionApi) => { }; }; -const applyToIniStruct = ( - api: types.IExtensionApi, - getPriorityManager: () => PriorityManager, - modIds: string[], -) => { +const applyToIniStruct = (api: types.IExtensionApi, modIds: string[]) => { const currentLO = getPersistentLoadOrder(api); const newLO: types.ILoadOrderEntry[] = [ ...currentLO.filter((entry) => !modIds.includes(entry.modId)), ]; - IniStructure.getInstance(api, getPriorityManager) + IniStructure.getInstance(api) .setINIStruct(newLO) .then(() => forceRefresh(api)); }; -export const onModsDisabled = ( - api: types.IExtensionApi, - priorityManager: () => PriorityManager, -) => { +export const onModsDisabled = (api: types.IExtensionApi) => { return async (modIds: string[], enabled: boolean, gameId: string) => { if (gameId !== GAME_ID || enabled) { return; } - applyToIniStruct(api, priorityManager, modIds); + applyToIniStruct(api, modIds); }; }; -export const onDidRemoveMod = ( - api: types.IExtensionApi, - priorityManager: () => PriorityManager, -) => { +export const onDidRemoveMod = (api: types.IExtensionApi) => { return async (gameId: string, modId: string, removeOpts: IRemoveModOptions) => { if (GAME_ID !== gameId || removeOpts?.willBeReplaced) { return Promise.resolve(); } - applyToIniStruct(api, priorityManager, [modId]); + applyToIniStruct(api, [modId]); }; }; -export const onDidPurge = (api: types.IExtensionApi, priorityManager: () => PriorityManager) => { +export const onDidPurge = (api: types.IExtensionApi) => { return async (profileId: string, deployment: Deployment) => { const state = api.getState(); const activeProfile = validateProfile(profileId, state); @@ -165,7 +151,7 @@ export const onDidPurge = (api: types.IExtensionApi, priorityManager: () => Prio return Promise.resolve(); } - return IniStructure.getInstance(api, priorityManager).revertLOFile(); + return IniStructure.getInstance(api).revertLOFile(); }; }; @@ -231,9 +217,6 @@ export const onProfileWillChange = (api: types.IExtensionApi) => { return; } - const priorityType = util.getSafe(state, getPriorityTypeBranch(), "prefix-based"); - api.store.dispatch(setPriorityType(priorityType)); - const lastProfId = selectors.lastActiveProfileForGame(state, profile.gameId); try { await storeToProfile(api, lastProfId).then(() => restoreFromProfile(api, profile.id)); @@ -245,22 +228,6 @@ export const onProfileWillChange = (api: types.IExtensionApi) => { }; }; -export const onSettingsChange = ( - api: types.IExtensionApi, - priorityManager: () => PriorityManager, -) => { - return (prev: string, current: any) => { - const state = api.getState(); - const activeProfile = selectors.activeProfile(state); - if (activeProfile?.gameId !== GAME_ID || priorityManager === undefined) { - return; - } - - const priorityType = util.getSafe(state, getPriorityTypeBranch(), "prefix-based"); - priorityManager().priorityType = priorityType; - }; -}; - function getScriptMergerTool(api) { const state = api.store.getState(); const scriptMerger = util.getSafe( diff --git a/extensions/games/game-witcher3/src/iconbarActions.ts b/extensions/games/game-witcher3/src/iconbarActions.ts index 9657a44bac..934c95cde1 100644 --- a/extensions/games/game-witcher3/src/iconbarActions.ts +++ b/extensions/games/game-witcher3/src/iconbarActions.ts @@ -3,17 +3,16 @@ import path from "path"; import { actions, selectors, types, util } from "@nexusmods/vortex-api"; +import { withPositionPrefix } from "./collectionLoadOrder"; import { GAME_ID, I18N_NAMESPACE } from "./common"; import IniStructure from "./iniParser"; import TW3LoadOrder, { importLoadOrder } from "./loadOrder"; import { makeOnContextImport } from "./mergeBackup"; import { getPersistentLoadOrder } from "./migrations"; -import { PriorityManager } from "./priorityManager"; import { forceRefresh, isLockedEntry } from "./util"; interface IProps { context: types.IExtensionContext; - getPriorityManager: () => PriorityManager; // getModLimitPatcher: () => ModLimitPatcher; } @@ -146,19 +145,12 @@ export const registerActions = (props: IProps) => { lhs.id.toLowerCase().localeCompare(rhs.id.toLowerCase()), ); - const newLO = [...locked, ...sorted].map((entry, idx) => ({ - ...entry, - data: { - prefix: idx + 1, - }, - })); + const newLO = withPositionPrefix([...locked, ...sorted]); context.api.store.dispatch(actions.setLoadOrder(profile.id, newLO as any)); // The refresh below makes the page re-read mods.settings, so the // new order has to reach the file first or it's just discarded. - await IniStructure.getInstance(context.api, props.getPriorityManager).setINIStruct( - newLO as any, - ); + await IniStructure.getInstance(context.api).setINIStruct(newLO); } catch (err) { context.api.showErrorNotification("Failed to sort alphabetically", err); } finally { diff --git a/extensions/games/game-witcher3/src/index.ts b/extensions/games/game-witcher3/src/index.ts index 7332189289..6fc951d698 100644 --- a/extensions/games/game-witcher3/src/index.ts +++ b/extensions/games/game-witcher3/src/index.ts @@ -23,7 +23,6 @@ import { onGameModeActivation, onModsDisabled, onProfileWillChange, - onSettingsChange, onWillDeploy, } from "./eventHandlers"; import { healthChecks, registerHealthCheckNotifications } from "./healthChecks"; @@ -47,7 +46,6 @@ import TW3LoadOrder from "./loadOrder"; import { canMergeXML, doMergeXML } from "./mergers"; import { getPersistentLoadOrder, migrate148 } from "./migrations"; import { testDLC, testTL } from "./modTypes"; -import { PriorityManager } from "./priorityManager"; import { W3Reducer } from "./reducers"; import { downloadScriptMerger, @@ -208,8 +206,6 @@ function prepareForModding(api: types.IExtensionApi) { }; } -let priorityManager: PriorityManager; -const getPriorityManager = () => priorityManager; // let modLimitPatcher: ModLimitPatcher; function main(context: types.IExtensionContext) { @@ -308,7 +304,7 @@ function main(context: types.IExtensionContext) { context.registerMigration((oldVersion) => migrate148(context, oldVersion) as any); - registerActions({ context, getPriorityManager }); + registerActions({ context }); context.optional.registerCollectionFeature( "witcher3_collection_data", @@ -357,7 +353,6 @@ function main(context: types.IExtensionContext) { const props = { onToggleModsState: toggleModsState, api: context.api, - getPriorityManager, }; context.registerLoadOrder(new TW3LoadOrder(props)); @@ -372,23 +367,17 @@ function main(context: types.IExtensionContext) { // () => Bluebird.resolve(testModLimitBreach(context.api, modLimitPatcher))); context.once(() => { - priorityManager = new PriorityManager(context.api, "prefix-based"); - IniStructure.getInstance(context.api, getPriorityManager); + IniStructure.getInstance(context.api); // modLimitPatcher = new ModLimitPatcher(context.api); context.api.events.on("gamemode-activated", onGameModeActivation(context.api)); context.api.events.on("profile-will-change", onProfileWillChange(context.api)); - context.api.events.on("mods-enabled", onModsDisabled(context.api, getPriorityManager)); + context.api.events.on("mods-enabled", onModsDisabled(context.api)); context.api.onAsync("will-deploy", onWillDeploy(context.api) as any); context.api.onAsync("did-deploy", onDidDeploy(context.api) as any); - context.api.onAsync("did-purge", onDidPurge(context.api, getPriorityManager) as any); - context.api.onAsync("did-remove-mod", onDidRemoveMod(context.api, getPriorityManager) as any); - - context.api.onStateChange( - ["settings", "witcher3"], - onSettingsChange(context.api, getPriorityManager) as any, - ); + context.api.onAsync("did-purge", onDidPurge(context.api) as any); + context.api.onAsync("did-remove-mod", onDidRemoveMod(context.api) as any); registerHealthCheckNotifications(context.api); }); diff --git a/extensions/games/game-witcher3/src/iniParser.ts b/extensions/games/game-witcher3/src/iniParser.ts index 9b3262639e..6db4c8295a 100644 --- a/extensions/games/game-witcher3/src/iniParser.ts +++ b/extensions/games/game-witcher3/src/iniParser.ts @@ -6,31 +6,25 @@ import IniParser, { IniFile, WinapiFormat } from "vortex-parse-ini"; import { GAME_ID, ResourceInaccessibleError, getLoadOrderFilePath } from "./common"; import { assignPriorities, ModSettingsEntry } from "./modSettingsPriority"; -import { PriorityManager } from "./priorityManager"; import { forceRefresh, isLockedEntry, getAllMods, getManuallyAddedMods } from "./util"; export default class IniStructure { private static instance: IniStructure = null; - public static getInstance( - api?: types.IExtensionApi, - priorityManager?: () => PriorityManager, - ): IniStructure { + public static getInstance(api?: types.IExtensionApi): IniStructure { if (!IniStructure.instance) { - if (api === undefined || priorityManager === undefined) { + if (api === undefined) { throw new Error("IniStructure is not context aware"); } - IniStructure.instance = new IniStructure(api, priorityManager); + IniStructure.instance = new IniStructure(api); } return IniStructure.instance; } private mIniStruct = {}; private mApi: types.IExtensionApi; - private mPriorityManager: PriorityManager; - constructor(api: types.IExtensionApi, priorityManager: () => PriorityManager) { + constructor(api: types.IExtensionApi) { this.mIniStruct = {}; this.mApi = api; - this.mPriorityManager = priorityManager(); } public async getIniStructure() { @@ -45,7 +39,6 @@ export default class IniStructure { .filter((entry) => isLockedEntry(entry.name)) .map((entry) => entry.name); const totalLocked: string[] = [...modMap.merged, ...manualLocked, ...managedLocked]; - this.mPriorityManager?.resetMaxPriority(totalLocked.length); const order = loadOrder ?? []; const enabledByName = new Map(order.map((entry) => [entry.id, entry.enabled])); diff --git a/extensions/games/game-witcher3/src/loadOrder.tsx b/extensions/games/game-witcher3/src/loadOrder.tsx index a050a5dbcd..bd6b9697a7 100644 --- a/extensions/games/game-witcher3/src/loadOrder.tsx +++ b/extensions/games/game-witcher3/src/loadOrder.tsx @@ -4,10 +4,10 @@ import { actions, fs, selectors, types, util } from "@nexusmods/vortex-api"; /* eslint-disable */ import React from "react"; +import { withPositionPrefix } from "./collectionLoadOrder"; import { ACTIVITY_ID_IMPORTING_LOADORDER, GAME_ID, LOCKED_PREFIX, UNI_PATCH } from "./common"; import IniStructure from "./iniParser"; import { getPersistentLoadOrder } from "./migrations"; -import { PriorityManager } from "./priorityManager"; import { IItemRendererProps } from "./types"; import { forceRefresh } from "./util"; import InfoComponent from "./views/InfoComponent"; @@ -15,7 +15,6 @@ import ItemRenderer from "./views/ItemRenderer"; export interface IBaseProps { api: types.IExtensionApi; - getPriorityManager: () => PriorityManager; onToggleModsState: (enable: boolean) => void; } @@ -32,7 +31,6 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { }>; private mApi: types.IExtensionApi; - private mPriorityManager: PriorityManager; constructor(props: IBaseProps) { this.gameId = GAME_ID; @@ -44,14 +42,13 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { return ; }; this.mApi = props.api; - this.mPriorityManager = props.getPriorityManager(); this.deserializeLoadOrder = this.deserializeLoadOrder.bind(this); this.serializeLoadOrder = this.serializeLoadOrder.bind(this); this.validate = this.validate.bind(this); } public async serializeLoadOrder(loadOrder: types.LoadOrder): Promise { - return IniStructure.getInstance(this.mApi, () => this.mPriorityManager).setINIStruct(loadOrder); + return IniStructure.getInstance(this.mApi).setINIStruct(loadOrder); } private readableNames = { [UNI_PATCH]: "Unification/Community Patch" }; @@ -87,12 +84,11 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { try { const unsorted: { [key: string]: any } = await IniStructure.getInstance( this.mApi, - () => this.mPriorityManager, ).readStructure(); const entries = Object.keys(unsorted) .sort((a, b) => unsorted[a].Priority - unsorted[b].Priority) .reduce( - (accum, iter, idx) => { + (accum, iter) => { const entry = unsorted[iter]; accum[iter.startsWith(LOCKED_PREFIX) ? "locked" : "regular"].push({ id: iter, @@ -100,18 +96,12 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { enabled: entry.Enabled === "1", modId: entry?.VK ?? iter, locked: iter.startsWith(LOCKED_PREFIX), - data: { - prefix: iter.startsWith(LOCKED_PREFIX) - ? accum.locked.length - : (entry?.Priority ?? idx + 1), - }, }); return accum; }, { locked: [], regular: [] }, ); - const finalEntries = [].concat(entries.locked, entries.regular); - return Promise.resolve(finalEntries); + return withPositionPrefix([].concat(entries.locked, entries.regular)); } catch (err) { // mods.settings is gone, which a purge does. Returning nothing here // replaces the stored order with an empty one, so fall back to the diff --git a/extensions/games/game-witcher3/src/loadOrderPriority.test.ts b/extensions/games/game-witcher3/src/loadOrderPriority.test.ts deleted file mode 100644 index e4f414efb7..0000000000 --- a/extensions/games/game-witcher3/src/loadOrderPriority.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { maxPriorityFrom } from "./loadOrderPriority"; - -// Only the fields maxPriorityFrom reads. -const entry = (id: string, pos: number, prefix?: string) => - ({ id, pos, data: prefix === undefined ? {} : { prefix } }) as never; - -describe("maxPriorityFrom", () => { - it("reads the prefix from the entry's data", () => { - // This is the shape deserializeLoadOrder and the load order migration - // produce, so it's what nearly every entry actually looks like. - const loadOrder = [entry("a", 0, "42"), entry("b", 1, "7")]; - expect(maxPriorityFrom(loadOrder, 0)).toBe(42); - }); - - it("falls back to the position when an entry has no prefix", () => { - const loadOrder = [entry("a", 3), entry("b", 1)]; - expect(maxPriorityFrom(loadOrder, 0)).toBe(3); - }); - - it("never returns less than the supplied minimum", () => { - expect(maxPriorityFrom([entry("a", 1, "2")], 10)).toBe(10); - }); - - it("returns the minimum for an empty load order", () => { - expect(maxPriorityFrom([], 5)).toBe(5); - }); -}); diff --git a/extensions/games/game-witcher3/src/loadOrderPriority.ts b/extensions/games/game-witcher3/src/loadOrderPriority.ts deleted file mode 100644 index 02f0d69906..0000000000 --- a/extensions/games/game-witcher3/src/loadOrderPriority.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Load order priority arithmetic, kept free of imports so it can be unit - * tested without pulling in the Vortex API. - */ - -/** Only the fields the priority calculation reads. */ -interface IPriorityEntry { - pos: number; - prefix?: string; - data?: { prefix?: string }; -} - -/** - * Highest priority currently in use, so a newly added mod can be placed after - * it. Entries carry their prefix under `data`; the flat form is what load - * orders persisted by older versions look like. - */ -export function maxPriorityFrom(loadOrder: ArrayLike, minPriority: number): number { - return Object.keys(loadOrder).reduce((prev, key) => { - const entry = loadOrder[key]; - const prefixVal = entry?.data?.prefix ?? entry?.prefix; - const intVal = prefixVal !== undefined ? parseInt(prefixVal, 10) : entry.pos; - const posVal = entry.pos; - if (posVal !== intVal) { - return intVal > prev ? intVal : prev; - } - return posVal > prev ? posVal : prev; - }, minPriority); -} diff --git a/extensions/games/game-witcher3/src/migrations.ts b/extensions/games/game-witcher3/src/migrations.ts index 761f90208f..e339c42f8e 100644 --- a/extensions/games/game-witcher3/src/migrations.ts +++ b/extensions/games/game-witcher3/src/migrations.ts @@ -2,6 +2,7 @@ import { selectors, types, util } from "@nexusmods/vortex-api"; /* eslint-disable */ import semver from "semver"; +import { withPositionPrefix } from "./collectionLoadOrder"; import { ILoadOrder, ILoadOrderEntry } from "./collections/types"; import { GAME_ID } from "./common"; @@ -80,7 +81,9 @@ export function getPersistentLoadOrder( return loadOrder; } if (typeof loadOrder === "object") { - return Object.entries(loadOrder).map(([key, item]) => convertDisplayItem(key, item)); + return withPositionPrefix( + Object.entries(loadOrder).map(([key, item]) => convertDisplayItem(key, item)), + ); } return []; } @@ -92,8 +95,5 @@ function convertDisplayItem(key: string, item: ILoadOrderEntry): types.ILoadOrde name: key, locked: item.locked, enabled: true, - data: { - prefix: item.prefix, - }, }; } diff --git a/extensions/games/game-witcher3/src/priorityManager.ts b/extensions/games/game-witcher3/src/priorityManager.ts deleted file mode 100644 index adfd0cf808..0000000000 --- a/extensions/games/game-witcher3/src/priorityManager.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* eslint-disable */ -import { selectors, types } from "@nexusmods/vortex-api"; - -import { GAME_ID } from "./common"; -import { maxPriorityFrom } from "./loadOrderPriority"; -import { getPersistentLoadOrder } from "./migrations"; - -export type PriorityType = "position-based" | "prefix-based"; - -export interface IOffsetMap { - [offset: number]: number; -} - -interface IProps { - state: types.IState; - profile: types.IProfile; - loadOrder: types.LoadOrder; - minPriority: number; -} - -export class PriorityManager { - private mApi: types.IExtensionApi; - private mPriorityType: PriorityType; - private mMaxPriority: number; - - constructor(api: types.IExtensionApi, priorityType: PriorityType) { - this.mApi = api; - this.mPriorityType = priorityType; - this.resetMaxPriority(); - } - - set priorityType(type: PriorityType) { - this.mPriorityType = type; - } - - get priorityType() { - return this.mPriorityType; - } - - public resetMaxPriority = (min?: number) => { - const props: IProps = this.genProps(min); - if (props === undefined) { - this.mMaxPriority = 0; - return; - } - this.mMaxPriority = this.getMaxPriority(props); - }; - - public getPriority = (loadOrder: types.LoadOrder, item: types.ILoadOrderEntry) => { - if (item === undefined) { - // Send it off to the end. - return ++this.mMaxPriority; - } - const minPriority = Object.keys(loadOrder).filter((key) => loadOrder[key]?.locked).length + 1; - - const itemIdx = loadOrder.findIndex((x) => x?.id === item.id); - if (itemIdx !== -1) { - if (this.mPriorityType === "position-based") { - const position = itemIdx + 1; - return position > minPriority ? position : ++this.mMaxPriority; - } else { - const prefixVal = loadOrder[itemIdx]?.data?.prefix ?? loadOrder[itemIdx]?.["prefix"]; - const intVal = prefixVal !== undefined ? parseInt(prefixVal, 10) : itemIdx; - const posVal = itemIdx; - if (posVal !== intVal && intVal > minPriority) { - return intVal; - } else { - return posVal > minPriority ? posVal : ++this.mMaxPriority; - } - } - } - - return ++this.mMaxPriority; - }; - - private genProps = (min?: number): IProps => { - const state: types.IState = this.mApi.getState(); - const lastProfId = selectors.lastActiveProfileForGame(state, GAME_ID); - if (lastProfId === undefined) { - return undefined; - } - const profile = selectors.profileById(state, lastProfId); - if (profile === undefined) { - return undefined; - } - - const loadOrder: types.LoadOrder = getPersistentLoadOrder(this.mApi); - - const lockedEntries = Object.keys(loadOrder).filter((key) => loadOrder[key]?.locked); - const minPriority = min ? min : lockedEntries.length; - return { state, profile, loadOrder, minPriority }; - }; - - public getMaxPriority = (props: IProps) => { - return maxPriorityFrom(props.loadOrder, props.minPriority); - }; -} diff --git a/extensions/games/game-witcher3/src/reducers.ts b/extensions/games/game-witcher3/src/reducers.ts index 0ed2073911..4c079f28a9 100644 --- a/extensions/games/game-witcher3/src/reducers.ts +++ b/extensions/games/game-witcher3/src/reducers.ts @@ -1,13 +1,10 @@ import { types, util } from "@nexusmods/vortex-api"; -import { setPriorityType, setRemasterNoticeSeen, setSuppressModLimitPatch } from "./actions"; +import { setRemasterNoticeSeen, setSuppressModLimitPatch } from "./actions"; // reducer export const W3Reducer: types.IReducerSpec = { reducers: { - [setPriorityType as any]: (state, payload) => { - return util.setSafe(state, ["prioritytype"], payload); - }, [setSuppressModLimitPatch as any]: (state, payload) => { return util.setSafe(state, ["suppressModLimitPatch"], payload); }, @@ -16,7 +13,6 @@ export const W3Reducer: types.IReducerSpec = { }, }, defaults: { - prioritytype: "prefix-based", suppressModLimitPatch: false, remasterNoticeSeen: false, }, From 4974ff39d42a01b4486eb86bae5409ed47632113 Mon Sep 17 00:00:00 2001 From: IDCs Date: Tue, 29 Sep 2026 05:37:56 +0100 Subject: [PATCH 09/10] Keep the Witcher 3 load order through a purge A purge deletes mods.settings, or rewrites it with only manually added mods, and a missing file reads as empty rather than failing. The load order page keeps the stored order in both cases, and the next deploy writes it back. mods.settings is built beside the real file and moved over it, so a concurrent read never sees it empty or half written. part of LAZ-1011 --- .../games/game-witcher3/src/iniParser.ts | 23 ++++++++++--- .../games/game-witcher3/src/loadOrder.tsx | 33 +++++++++++-------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/extensions/games/game-witcher3/src/iniParser.ts b/extensions/games/game-witcher3/src/iniParser.ts index 6db4c8295a..4eec3eb0d1 100644 --- a/extensions/games/game-witcher3/src/iniParser.ts +++ b/extensions/games/game-witcher3/src/iniParser.ts @@ -1,4 +1,5 @@ /* eslint-disable */ +import { rename } from "node:fs/promises"; import path from "path"; import { fs, selectors, types, util } from "@nexusmods/vortex-api"; @@ -22,11 +23,17 @@ export default class IniStructure { } private mIniStruct = {}; private mApi: types.IExtensionApi; + private mRevertedByPurge = false; constructor(api: types.IExtensionApi) { this.mIniStruct = {}; this.mApi = api; } + /** mods.settings holds only what a purge left behind, not the profile's load order. */ + public get revertedByPurge(): boolean { + return this.mRevertedByPurge; + } + public async getIniStructure() { return this.mIniStruct; } @@ -58,13 +65,15 @@ export default class IniStructure { }; } this.mIniStruct = struct; - return this.writeToModSettings(); + await this.writeToModSettings(); + this.mRevertedByPurge = false; } public async revertLOFile() { const state = this.mApi.getState(); const profile = selectors.activeProfile(state); if (!!profile && profile.gameId === GAME_ID) { + this.mRevertedByPurge = true; const manuallyAdded = await getManuallyAddedMods(this.mApi); if (manuallyAdded.length > 0) { const newStruct = {}; @@ -159,11 +168,14 @@ export default class IniStructure { public async writeToModSettings(): Promise { const filePath = getLoadOrderFilePath(); + // Built beside the real file and moved over it, so a concurrent read never + // sees it empty or half written. + const tempPath = `${filePath}.vortex_tmp`; const parser = new IniParser(new WinapiFormat()); try { - await fs.removeAsync(filePath); - await fs.writeFileAsync(filePath, "", { encoding: "utf8" }); - const ini = await this.ensureModSettings(); + await fs.ensureDirWritableAsync(path.dirname(filePath)); + await fs.writeFileAsync(tempPath, "", { encoding: "utf8" }); + const ini = await parser.read(tempPath); const struct = Object.keys(this.mIniStruct).sort( (a, b) => this.mIniStruct[a].Priority - this.mIniStruct[b].Priority, ); @@ -184,7 +196,8 @@ export default class IniStructure { VK: this.mIniStruct[key].VK, }; } - await parser.write(filePath, ini); + await parser.write(tempPath, ini); + await rename(tempPath, filePath); return Promise.resolve(); } catch (err) { return err.path !== undefined && ["EPERM", "EBUSY"].includes(err.code) diff --git a/extensions/games/game-witcher3/src/loadOrder.tsx b/extensions/games/game-witcher3/src/loadOrder.tsx index bd6b9697a7..7e687a7181 100644 --- a/extensions/games/game-witcher3/src/loadOrder.tsx +++ b/extensions/games/game-witcher3/src/loadOrder.tsx @@ -1,15 +1,21 @@ import path from "path"; -import { actions, fs, selectors, types, util } from "@nexusmods/vortex-api"; +import { actions, fs, log, selectors, types, util } from "@nexusmods/vortex-api"; /* eslint-disable */ import React from "react"; import { withPositionPrefix } from "./collectionLoadOrder"; -import { ACTIVITY_ID_IMPORTING_LOADORDER, GAME_ID, LOCKED_PREFIX, UNI_PATCH } from "./common"; +import { + ACTIVITY_ID_IMPORTING_LOADORDER, + GAME_ID, + getLoadOrderFilePath, + LOCKED_PREFIX, + UNI_PATCH, +} from "./common"; import IniStructure from "./iniParser"; import { getPersistentLoadOrder } from "./migrations"; import { IItemRendererProps } from "./types"; -import { forceRefresh } from "./util"; +import { fileExists, forceRefresh } from "./util"; import InfoComponent from "./views/InfoComponent"; import ItemRenderer from "./views/ItemRenderer"; @@ -81,10 +87,15 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { return `${util.renderModName(mod)} (${entry.name})`; }; + const stored = getPersistentLoadOrder(this.mApi); + const iniStructure = IniStructure.getInstance(this.mApi); try { - const unsorted: { [key: string]: any } = await IniStructure.getInstance( - this.mApi, - ).readStructure(); + // A purge rewrites or deletes mods.settings, and a missing file reads as + // empty rather than failing, so neither may replace the stored order. + if (iniStructure.revertedByPurge || !(await fileExists(getLoadOrderFilePath()))) { + return stored; + } + const unsorted: { [key: string]: any } = await iniStructure.readStructure(); const entries = Object.keys(unsorted) .sort((a, b) => unsorted[a].Priority - unsorted[b].Priority) .reduce( @@ -103,14 +114,8 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { ); return withPositionPrefix([].concat(entries.locked, entries.regular)); } catch (err) { - // mods.settings is gone, which a purge does. Returning nothing here - // replaces the stored order with an empty one, so fall back to the - // order the profile already holds. - const persistent = state.persistent as typeof state.persistent & { - loadOrder?: Record; // key: profile id - }; - const stored = persistent.loadOrder?.[activeProfile.id]; - return Array.isArray(stored) ? stored : []; + log("warn", "failed to read mods.settings, keeping the stored load order", err); + return stored; } } From 1af67f4cb0699bd3e41c0318e218e9248a8901bf Mon Sep 17 00:00:00 2001 From: IDCs Date: Tue, 29 Sep 2026 05:38:36 +0100 Subject: [PATCH 10/10] Give the Witcher 3 load order rows a uniform height Rows use the default renderer's fixed-height styling and the view mod button is an embedded icon, so every row is the same height and the list can be virtualized. part of LAZ-1011 --- extensions/games/game-witcher3/src/loadOrder.tsx | 2 ++ extensions/games/game-witcher3/src/views/ItemRenderer.tsx | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/extensions/games/game-witcher3/src/loadOrder.tsx b/extensions/games/game-witcher3/src/loadOrder.tsx index 7e687a7181..cc552880e0 100644 --- a/extensions/games/game-witcher3/src/loadOrder.tsx +++ b/extensions/games/game-witcher3/src/loadOrder.tsx @@ -28,6 +28,7 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { public gameId: string; public toggleableEntries?: boolean | undefined; public clearStateOnPurge?: boolean | undefined; + public uniformRowHeight?: boolean | undefined; public usageInstructions?: React.ComponentType<{}>; public noCollectionGeneration?: boolean | undefined; public customItemRenderer?: React.ComponentType<{ @@ -42,6 +43,7 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { this.gameId = GAME_ID; this.clearStateOnPurge = true; this.toggleableEntries = true; + this.uniformRowHeight = true; this.noCollectionGeneration = true; this.usageInstructions = () => ; this.customItemRenderer = (props) => { diff --git a/extensions/games/game-witcher3/src/views/ItemRenderer.tsx b/extensions/games/game-witcher3/src/views/ItemRenderer.tsx index 7d8e9a82dc..07358ecfb9 100644 --- a/extensions/games/game-witcher3/src/views/ItemRenderer.tsx +++ b/extensions/games/game-witcher3/src/views/ItemRenderer.tsx @@ -85,7 +85,7 @@ function ViewModIcon(props: IProps): JSX.Element { const unmanaged = isExternal(item.loEntry) || item.loEntry.modId === item.loEntry.name; return !unmanaged && item.loEntry.modId !== undefined ? ( entry.id === item.loEntry.id) + 1; - let classes = ["load-order-entry"]; + let classes = ["load-order-entry", "fblo-uniform-row"]; if (className !== undefined) { classes = classes.concat(className.split(" ")); } @@ -177,7 +177,9 @@ function DraggableEntry(props: IProps): JSX.Element { onApplyIndex={onApplyIndex} /> {renderValidationError(props)} -

{key}

+

+ {key} +

{renderExternalBanner(item.loEntry)} {checkBox()}