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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions extensions/games/game-witcher3/assets/MD5Cache.json

This file was deleted.

10 changes: 8 additions & 2 deletions extensions/games/game-witcher3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand Down
4 changes: 2 additions & 2 deletions extensions/games/game-witcher3/src/actions.ts
Original file line number Diff line number Diff line change
@@ -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);
80 changes: 80 additions & 0 deletions extensions/games/game-witcher3/src/collectionLoadOrder.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, types.IMod> =>
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 }]);
});
});
54 changes: 54 additions & 0 deletions extensions/games/game-witcher3/src/collectionLoadOrder.ts
Original file line number Diff line number Diff line change
@@ -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<T extends types.ILoadOrderEntry>(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<string, types.IMod>, // 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]);
}),
);
}
5 changes: 0 additions & 5 deletions extensions/games/game-witcher3/src/collections/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ export interface ILoadOrderEntry<T = any> {
// 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;

Expand Down
54 changes: 10 additions & 44 deletions extensions/games/game-witcher3/src/collections/util.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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<IEntry[]> {
let fileEntries: IEntry[] = [];
await turbowalk(dirPath, (entries: IEntry[]) => {
Expand All @@ -74,8 +40,8 @@ export async function walkDirPath(dirPath: string): Promise<IEntry[]> {
export async function prepareFileData(dirPath: string): Promise<Buffer> {
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,
Expand Down Expand Up @@ -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) {
Expand Down
76 changes: 16 additions & 60 deletions extensions/games/game-witcher3/src/common.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -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",
Expand All @@ -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_";
Expand Down
Loading