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 6346f08afc..7b85806345 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", @@ -13,8 +14,13 @@ "config": { "game": "The Witcher 3" }, + "dependencies": { + "semver": "catalog:" + }, "devDependencies": { - "@nexusmods/vortex-api": "workspace:*" + "@nexusmods/vortex-api": "workspace:*", + "@types/semver": "catalog:", + "@vortex/extension-test-mocks": "workspace:*" }, "nx": { "tags": [ diff --git a/extensions/games/game-witcher3/src/actions.ts b/extensions/games/game-witcher3/src/actions.ts index 16b9d44b5e..25f9bc129f 100644 --- a/extensions/games/game-witcher3/src/actions.ts +++ b/extensions/games/game-witcher3/src/actions.ts @@ -1,8 +1,8 @@ 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, ); + +export const setRemasterNoticeSeen = createAction("TW3_SET_REMASTER_NOTICE_SEEN", (seen) => seen); 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..5640ebf4f3 --- /dev/null +++ b/extensions/games/game-witcher3/src/collectionLoadOrder.test.ts @@ -0,0 +1,80 @@ +import { types } from "@nexusmods/vortex-api"; +import { describe, expect, it } from "vitest"; + +import { genCollectionLoadOrder, withPositionPrefix } from "./collectionLoadOrder"; + +const entry = (modId: string): types.IFBLOLoadOrderEntry => ({ + id: modId, + modId, + name: modId, + enabled: true, +}); + +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", () => { + const loadOrder = [entry("modC"), entry("modA"), entry("modB")]; + + const result = genCollectionLoadOrder(loadOrder, mods("modA", "modB", "modC")); + + expect(modIds(result)).toEqual(["modC", "modA", "modB"]); + }); + + it("keeps locked entries that have no corresponding mod", () => { + const loadOrder = [entry("mod0000____CompilationTrigger"), entry("modA")]; + + 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"), entry("modGone"), entry("modB")]; + + const result = genCollectionLoadOrder(loadOrder, mods("modA", "modB")); + + expect(modIds(result)).toEqual(["modA", "modB"]); + }); + + it("drops collections", () => { + const loadOrder = [entry("modA"), entry("someCollection")]; + const available = mods("modA"); + available.someCollection = mod("someCollection", "collection"); + + const result = genCollectionLoadOrder(loadOrder, available); + + expect(modIds(result)).toEqual(["modA"]); + }); + + it("numbers the exported entries by their position", () => { + const loadOrder = [entry("modC"), entry("modGone"), entry("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.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 new file mode 100644 index 0000000000..b05e707a12 --- /dev/null +++ b/extensions/games/game-witcher3/src/collectionLoadOrder.ts @@ -0,0 +1,54 @@ +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 + ); +} + +/** + * 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 + * order they arrive in. + */ +export function genCollectionLoadOrder( + loadOrder: types.IFBLOLoadOrderEntry[], + mods: Record, // key: mod id + collection?: types.IMod, +): types.LoadOrder { + 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/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/common.ts b/extensions/games/game-witcher3/src/common.ts index 5deef048e5..606863078b 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,37 +64,16 @@ 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); } -export function getPriorityTypeBranch() { - return ["settings", "witcher3", "prioritytype"]; +export function getDx12UserSettingsPath() { + return path.join(util.getVortexPath("documents"), "The Witcher 3", DX12_USER_SETTINGS_FILENAME); +} + +export function getRemasterNoticeSeenBranch() { + return ["settings", "witcher3", "remasterNoticeSeen"]; } export function getSuppressModLimitBranch() { @@ -135,6 +97,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 +106,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/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/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..de0dfa252b 100644 --- a/extensions/games/game-witcher3/src/eventHandlers.ts +++ b/extensions/games/game-witcher3/src/eventHandlers.ts @@ -1,30 +1,84 @@ /* eslint-disable */ import { actions, types, selectors, util } from "@nexusmods/vortex-api"; -import { setPriorityType } from "./actions"; +import { 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"; import { getPersistentLoadOrder } from "./migrations"; -import { PriorityManager } from "./priorityManager"; import { IRemoveModOptions } from "./types"; import { validateProfile, 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) { @@ -35,8 +89,7 @@ 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 { await storeToProfile(api, lastProfId).then(() => restoreFromProfile(api, activeProf?.id)); @@ -62,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); @@ -108,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(); }; }; @@ -174,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)); @@ -188,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( @@ -218,10 +242,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 +296,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/iconbarActions.ts b/extensions/games/game-witcher3/src/iconbarActions.ts index a2c9566615..934c95cde1 100644 --- a/extensions/games/game-witcher3/src/iconbarActions.ts +++ b/extensions/games/game-witcher3/src/iconbarActions.ts @@ -3,16 +3,16 @@ import path from "path"; import { actions, selectors, types, util } from "@nexusmods/vortex-api"; -import { GAME_ID, I18N_NAMESPACE, LOCKED_PREFIX } from "./common"; +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 } from "./util"; +import { forceRefresh, isLockedEntry } from "./util"; interface IProps { context: types.IExtensionContext; - getPriorityManager: () => PriorityManager; // getModLimitPatcher: () => ModLimitPatcher; } @@ -106,18 +106,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 +129,33 @@ 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 = withPositionPrefix([...locked, ...sorted]); - 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).setINIStruct(newLO); + } catch (err) { + context.api.showErrorNotification("Failed to sort alphabetically", err); + } finally { + forceRefresh(context.api); + } }, }, ], diff --git a/extensions/games/game-witcher3/src/index.ts b/extensions/games/game-witcher3/src/index.ts index db5a866104..6fc951d698 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, @@ -21,9 +23,9 @@ import { onGameModeActivation, onModsDisabled, onProfileWillChange, - onSettingsChange, onWillDeploy, } from "./eventHandlers"; +import { healthChecks, registerHealthCheckNotifications } from "./healthChecks"; import { registerActions } from "./iconbarActions"; import IniStructure from "./iniParser"; import { @@ -44,9 +46,13 @@ 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, getScriptMergerDir, setMergerConfig } from "./scriptmerger"; +import { + downloadScriptMerger, + getScriptMergerDir, + repairStaleScriptMerger, + setMergerConfig, +} from "./scriptmerger"; import { getDLCPath, getAllMods, @@ -76,7 +82,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 +102,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 +138,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,20 +179,33 @@ 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), + ), + ) ); }; } -let priorityManager: PriorityManager; -const getPriorityManager = () => priorityManager; // let modLimitPatcher: ModLimitPatcher; function main(context: types.IExtensionContext) { @@ -164,7 +221,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", }, @@ -243,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", @@ -292,32 +353,33 @@ function main(context: types.IExtensionContext) { const props = { onToggleModsState: toggleModsState, api: context.api, - 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', // () => 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.onAsync("did-purge", onDidPurge(context.api) as any); + context.api.onAsync("did-remove-mod", onDidRemoveMod(context.api) as any); - context.api.onStateChange( - ["settings", "witcher3"], - onSettingsChange(context.api, getPriorityManager) as any, - ); + registerHealthCheckNotifications(context.api); }); return true; } diff --git a/extensions/games/game-witcher3/src/iniParser.ts b/extensions/games/game-witcher3/src/iniParser.ts index 4d61bd67ba..4eec3eb0d1 100644 --- a/extensions/games/game-witcher3/src/iniParser.ts +++ b/extensions/games/game-witcher3/src/iniParser.ts @@ -1,35 +1,37 @@ /* eslint-disable */ +import { rename } from "node:fs/promises"; import path from "path"; 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 { PriorityManager } from "./priorityManager"; +import { assignPriorities, ModSettingsEntry } from "./modSettingsPriority"; 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) { + private mRevertedByPurge = false; + constructor(api: types.IExtensionApi) { this.mIniStruct = {}; this.mApi = api; - this.mPriorityManager = priorityManager(); + } + + /** 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() { @@ -38,52 +40,40 @@ 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]; + + 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; - }, {}); - return this.writeToModSettings(); + } + this.mIniStruct = struct; + 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 = {}; @@ -178,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, ); @@ -203,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/installers.test.ts b/extensions/games/game-witcher3/src/installers.test.ts new file mode 100644 index 0000000000..055fc18e3d --- /dev/null +++ b/extensions/games/game-witcher3/src/installers.test.ts @@ -0,0 +1,55 @@ +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"; + +// 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([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([path.join("modFoo", SCRIPT)], GAME_ID); + expect(result.supported).toBeFalsy(); + }); + + it("ignores archives for other games", async () => { + const result = await testSupportedContent([SCRIPT], "skyrim"); + expect(result.supported).toBeFalsy(); + }); +}); + +describe("installContent", () => { + const DEST = path.join(STAGING, "Some Mod-1234.installing"); + + it("returns instructions in the shape the install manager expects", async () => { + 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([SCRIPT], 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( + [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/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/loadOrder.tsx b/extensions/games/game-witcher3/src/loadOrder.tsx index 2a784bb167..cc552880e0 100644 --- a/extensions/games/game-witcher3/src/loadOrder.tsx +++ b/extensions/games/game-witcher3/src/loadOrder.tsx @@ -1,21 +1,26 @@ 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 { ACTIVITY_ID_IMPORTING_LOADORDER, GAME_ID, LOCKED_PREFIX, UNI_PATCH } from "./common"; +import { withPositionPrefix } from "./collectionLoadOrder"; +import { + ACTIVITY_ID_IMPORTING_LOADORDER, + GAME_ID, + getLoadOrderFilePath, + 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 { fileExists, forceRefresh } from "./util"; import InfoComponent from "./views/InfoComponent"; import ItemRenderer from "./views/ItemRenderer"; export interface IBaseProps { api: types.IExtensionApi; - getPriorityManager: () => PriorityManager; onToggleModsState: (enable: boolean) => void; } @@ -23,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<{ @@ -32,26 +38,25 @@ class TW3LoadOrder implements types.ILoadOrderGameInfo { }>; private mApi: types.IExtensionApi; - private mPriorityManager: PriorityManager; constructor(props: IBaseProps) { this.gameId = GAME_ID; this.clearStateOnPurge = true; this.toggleableEntries = true; + this.uniformRowHeight = true; this.noCollectionGeneration = true; this.usageInstructions = () => ; this.customItemRenderer = (props) => { 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" }; @@ -84,15 +89,19 @@ 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, - () => this.mPriorityManager, - ).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( - (accum, iter, idx) => { + (accum, iter) => { const entry = unsorted[iter]; accum[iter.startsWith(LOCKED_PREFIX) ? "locked" : "regular"].push({ id: iter, @@ -100,20 +109,15 @@ 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) { - return; + log("warn", "failed to read mods.settings, keeping the stored load order", err); + return stored; } } 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/mergerPaths.test.ts b/extensions/games/game-witcher3/src/mergerPaths.test.ts new file mode 100644 index 0000000000..b4296b70f5 --- /dev/null +++ b/extensions/games/game-witcher3/src/mergerPaths.test.ts @@ -0,0 +1,54 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { isMergerToolValidForRoot, isPathWithinRoot, mergerDirForRoot } from "./mergerPaths"; + +// 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", () => { + 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. + expect(isMergerToolValidForRoot(path.join(GOG, MERGER_EXE), STEAM)).toBe(false); + }); + + it("ignores case and trailing separators", () => { + 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(path.join(`${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(path.join(STEAM, "x.exe"), undefined)).toBe(false); + }); +}); + +describe("mergerDirForRoot", () => { + it("places the merger inside the game root", () => { + expect(mergerDirForRoot(STEAM)).toBe(path.join(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/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/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/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/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/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 deleted file mode 100644 index be8cbadc16..0000000000 --- a/extensions/games/game-witcher3/src/priorityManager.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* eslint-disable */ -import { selectors, types } from "@nexusmods/vortex-api"; - -import { GAME_ID } from "./common"; -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) => { - 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); - }; -} diff --git a/extensions/games/game-witcher3/src/reducers.ts b/extensions/games/game-witcher3/src/reducers.ts index d2ca0c72b7..4c079f28a9 100644 --- a/extensions/games/game-witcher3/src/reducers.ts +++ b/extensions/games/game-witcher3/src/reducers.ts @@ -1,19 +1,19 @@ import { types, util } from "@nexusmods/vortex-api"; -import { setPriorityType, 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); }, + [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/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 417ac7d56f..d05288d67e 100644 --- a/extensions/games/game-witcher3/src/scriptmerger.ts +++ b/extensions/games/game-witcher3/src/scriptmerger.ts @@ -9,15 +9,16 @@ import _ from "lodash"; import semver from "semver"; import { Builder, parseStringPromise } from "xml2js"; +import { isMergerToolValidForRoot, mergerDirForRoot } from "./mergerPaths"; +import { latestMergerRelease, mergerDownloadProblem } from "./mergerRelease"; import { IIncomingGithubHttpHeaders } from "./types"; +import { fileExists, 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"; -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) => { @@ -131,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) { @@ -213,10 +164,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); } @@ -242,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")); } @@ -313,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, @@ -352,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", { @@ -401,7 +357,11 @@ 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; + } api.sendNotification({ type: "error", message: api.translate("Please install Script Merger manually", { ns: "game-witcher3" }), @@ -416,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(); } @@ -490,6 +450,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 +527,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); diff --git a/extensions/games/game-witcher3/src/util.ts b/extensions/games/game-witcher3/src/util.ts index ee61250800..fccf38eabd 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); } }); }, @@ -305,15 +350,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) { diff --git a/extensions/games/game-witcher3/src/views/ItemRenderer.tsx b/extensions/games/game-witcher3/src/views/ItemRenderer.tsx index b4a996e903..07358ecfb9 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,9 +81,11 @@ 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 ? ( 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(" ")); } @@ -175,9 +177,11 @@ function renderDraggable(props: IProps): JSX.Element { onApplyIndex={onApplyIndex} /> {renderValidationError(props)} -

{key}

+

+ {key} +

{renderExternalBanner(item.loEntry)} - {renderViewModIcon(props)} + {checkBox()} {lock()} 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 ea2e75e8ae..939352c3c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2625,10 +2625,20 @@ 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 extensions/games/game-wolcen: devDependencies: