Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
67f1843
Normalize X Rebirth + add per-mod health checks + game-extension-test…
halgari May 12, 2026
914b19c
Normalize XCOM 2 extension
halgari May 8, 2026
0dcfad3
Refactor XCOM 2 extension to data-driven game config
halgari May 8, 2026
110fdde
Add characterization tests for XCOM 2 extension
halgari May 8, 2026
89d998c
feat(game-xcom2): opt into game-extension-test harness
halgari May 12, 2026
28b63d1
feat(installer-spec): add filter + flatten to IInstallerInstall
halgari May 12, 2026
b94a417
feat(game-xcom2): character pool installer
halgari May 12, 2026
ae14161
feat(game-xcom2): skip-heuristics for non-installable upload shapes
halgari May 12, 2026
7654bdb
feat(game-xcom2): config / localization drop-in installer
halgari May 12, 2026
ba595e5
feat(game-xcom2): skip raw cooked content + voice-pack uploads
halgari May 12, 2026
e095b6c
feat(game-xcom2): save-game installer
halgari May 12, 2026
6efcc11
feat(game-xcom2): widen documentation skip to images + empty manifests
halgari May 12, 2026
dc39b32
feat(game-xcom2): widen ReShade skip to .cfg/.undef and path components
halgari May 12, 2026
cbc86ec
fix(game-xcom2): require absent .XComMod for ModBuddy source skip
halgari May 12, 2026
3913015
test(game-xcom2): cover load-order Vortex call points + fix steam-id …
halgari May 13, 2026
2c27487
test(game-xcom2): cover modType Vortex call points
halgari May 13, 2026
29fbfe9
test(game-xcom2): cover health-check Vortex call points
halgari May 13, 2026
85b0abe
refactor(game-xcom2): use shared vortex-api test mock + clean up tests
halgari May 19, 2026
daa1b63
fix(game-xcom2): remove non-existent copy-extension.mjs from build sc…
halgari May 19, 2026
3e6e946
chore: regenerate pnpm-lock.yaml after rebase on master
halgari May 20, 2026
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
20 changes: 20 additions & 0 deletions etc/vortex.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ function bbcodeToHTML(input: string): string;
function buildCopyInstructions(files: readonly string[], opts: {
stripCommonRoot: boolean;
modType?: string;
filter?: IInstallerFilter;
flatten?: boolean;
}): IInstallResult;

// @public
Expand Down Expand Up @@ -2707,8 +2709,25 @@ interface IInstallationDetails {
modReference?: IModReference;
}

// @public
type IInstallerFilter = {
kind: "extensions";
list: readonly string[];
} | {
kind: "regex";
patterns: readonly RegExp[];
} | {
kind: "filename";
names: readonly string[];
} | {
kind: "custom";
predicate: (file: string) => boolean;
};

// @public
interface IInstallerInstall {
filter?: IInstallerFilter;
flatten?: boolean;
stripCommonRoot: boolean;
}

Expand Down Expand Up @@ -6184,6 +6203,7 @@ declare namespace types {
IModHealthCheck,
InstallerMatchMode,
IInstallerMatch,
IInstallerFilter,
IInstallerInstall,
IInstallerSpec,
InstallerSpecInstallFunc,
Expand Down
1 change: 1 addition & 0 deletions extensions/games/game-xcom2/__mocks__/electron-remote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const app = { getVersion: () => "1.0.0-test" };
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions extensions/games/game-xcom2/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as path from "node:path";

import { createConfig, bundle } from "../../../scripts/extensions-rolldown.mjs";

const extensionPath = path.resolve(import.meta.dirname);
const entryPoint = path.resolve(extensionPath, "src", "index.ts");
const output = path.resolve(extensionPath, "dist", "index.cjs");

const config = createConfig(entryPoint, output);
await bundle(config);
27 changes: 25 additions & 2 deletions extensions/games/game-xcom2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"description": "Support for XCOM 2 and XCOM 2: War of the Chosen",
"scripts": {
"_assets": "copyfiles -u 1 -f ./assets/* dist",
"build": "copyfiles -u 1 -f ./src/index.js dist && pnpm run _assets && pnpm extractInfo"
"build": "node build.mjs && pnpm run _assets && pnpm extractInfo",
"test": "vitest run --config vitest.config.ts"
},
"author": "Black Tree Gaming Ltd.",
"license": "GPL-3.0",
Expand All @@ -13,8 +14,13 @@
"config": {
"game": "XCOM 2"
},
"vortex": {
"gameExtensionTest": true
},
"devDependencies": {
"@nexusmods/vortex-api": "workspace:*"
"@nexusmods/vortex-api": "workspace:*",
"@vortex/game-extension-test": "workspace:*",
"vitest": "catalog:"
},
"nx": {
"tags": [
Expand All @@ -27,6 +33,23 @@
"typescript",
"vortex-api"
]
},
"typecheck": {
"inputs": [
"default",
"typescript",
"vortex-api"
]
},
"test:game-extensions": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter @vortex/game-extension-test run start -- --game game-xcom2"
},
"inputs": [
"{projectRoot}/src/**/*.ts"
],
"cache": false
}
}
}
Expand Down
210 changes: 210 additions & 0 deletions extensions/games/game-xcom2/src/diagnostic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import * as path from "node:path";

import { types } from "vortex-api";

import { XCOM2_MOD_TYPES } from "./installers";

const MOD_EXT = ".XComMod";
const CHARACTER_POOL_EXT = ".bin";

/**
* Save-file name shape — matches the installer's SAVE_NAME_RE.
* `save_<name>` or `save<digits>`, no extension. Anchored to basename via
* `^` because health checks see flattened deploy paths (just the basename
* after the installer's `flatten: true`).
*/
const SAVE_NAME_RE = /^save(_[^.\\/]+|\d+)$/i;

const CATEGORY = types.HealthCheckCategory.Mods;
const TRIGGERS: types.HealthCheckTrigger[] = [
types.HealthCheckTrigger.ModsChanged,
types.HealthCheckTrigger.Manual,
];
const SEVERITY_INFO = types.HealthCheckSeverity.Info;
const SEVERITY_WARNING = types.HealthCheckSeverity.Warning;

function passed(checkId: string, message: string, startedAt: number): types.IHealthCheckResult {
return {
checkId,
status: "passed",
severity: SEVERITY_INFO,
message,
executionTime: Date.now() - startedAt,
timestamp: new Date(),
};
}

function warning(
checkId: string,
message: string,
details: string,
startedAt: number,
): types.IHealthCheckResult {
return {
checkId,
status: "warning",
severity: SEVERITY_WARNING,
message,
details,
executionTime: Date.now() - startedAt,
timestamp: new Date(),
};
}

const isXComMod = (f: string) => path.extname(f).toLowerCase() === MOD_EXT.toLowerCase();
const isCharacterPoolBin = (f: string) =>
path.extname(f).toLowerCase() === CHARACTER_POOL_EXT.toLowerCase();
const isSaveFile = (f: string) => SAVE_NAME_RE.test(path.basename(f));

const isCharacterPoolMod = (mod: types.IMod): boolean =>
(mod.attributes.modType as string | undefined) === XCOM2_MOD_TYPES.characterPool;

const isSaveMod = (mod: types.IMod): boolean =>
(mod.attributes.modType as string | undefined) === XCOM2_MOD_TYPES.save;

function makeCheck(
id: string,
name: string,
description: string,
checkMod: types.IModHealthCheck["checkMod"],
): types.IModHealthCheck {
return {
id,
name,
description,
category: CATEGORY,
severity: SEVERITY_WARNING,
triggers: TRIGGERS,
checkMod,
};
}

export const healthChecks: types.IModHealthCheck[] = [
makeCheck(
"xcom2-mod-has-files",
"XCOM 2 — mod has files",
"Verifies that the installer produced at least one file.",
async (_api, mod) => {
const startedAt = Date.now();
if (mod.files.length === 0) {
return warning(
"xcom2-mod-has-files",
"Installer produced no files",
"An installer matched but emitted zero file instructions.",
startedAt,
);
}
return passed("xcom2-mod-has-files", "Install output has at least one file", startedAt);
},
),

makeCheck(
"xcom2-has-xcommod-file",
"XCOM 2 — output contains a .XComMod file",
"Verifies that the install output contains at least one .XComMod file.",
async (_api, mod) => {
const startedAt = Date.now();
if (isCharacterPoolMod(mod) || isSaveMod(mod)) {
return passed(
"xcom2-has-xcommod-file",
"Non-.XComMod mod type; check not applicable",
startedAt,
);
}
if (!mod.files.some(isXComMod)) {
return warning(
"xcom2-has-xcommod-file",
"No .XComMod file in install output",
"The XCOM 2 installer only matches archives containing at least one .XComMod " +
"descriptor and copies the surrounding folder. Its absence after install means " +
"the installer matched non-XCOM content or the copy step dropped the descriptor.",
startedAt,
);
}
return passed("xcom2-has-xcommod-file", "Install output contains a .XComMod file", startedAt);
},
),

makeCheck(
"xcom2-xCommods-attribute-set",
"XCOM 2 — xComMods attribute populated",
"Verifies that the installer recorded the xComMods attribute.",
async (_api, mod) => {
const startedAt = Date.now();
if (isCharacterPoolMod(mod) || isSaveMod(mod)) {
return passed(
"xcom2-xCommods-attribute-set",
"Non-.XComMod mod type; xComMods attribute check not applicable",
startedAt,
);
}
const value = mod.attributes.xComMods;
if (!Array.isArray(value) || value.length === 0) {
return warning(
"xcom2-xCommods-attribute-set",
"xComMods attribute missing or empty",
"The XCOM 2 installer always emits xComMods as a non-empty array of mod " +
"descriptor basenames. Its absence means the install path didn't complete.",
startedAt,
);
}
return passed("xcom2-xCommods-attribute-set", "xComMods attribute populated", startedAt);
},
),

makeCheck(
"xcom2-character-pool-has-bin",
"XCOM 2 — character pool output contains a .bin file",
"Verifies that a character pool mod's install output contains at least one .bin file.",
async (_api, mod) => {
const startedAt = Date.now();
if (!isCharacterPoolMod(mod)) {
return passed(
"xcom2-character-pool-has-bin",
"Not a character pool mod; check not applicable",
startedAt,
);
}
if (!mod.files.some(isCharacterPoolBin)) {
return warning(
"xcom2-character-pool-has-bin",
"Character pool mod has no .bin file",
"The character-pool installer filters archives to .bin only. Empty output " +
"after install means the archive contained no recognisable character pool " +
"files, or the install path didn't complete.",
startedAt,
);
}
return passed(
"xcom2-character-pool-has-bin",
"Character pool mod has at least one .bin file",
startedAt,
);
},
),

makeCheck(
"xcom2-save-deployed",
"XCOM 2 — save mod output contains a save file",
"Verifies that a save mod's install output contains at least one file " +
"with the XCOM 2 save-name shape (save_<name> or save<digits>).",
async (_api, mod) => {
const startedAt = Date.now();
if (!isSaveMod(mod)) {
return passed("xcom2-save-deployed", "Not a save mod; check not applicable", startedAt);
}
if (!mod.files.some(isSaveFile)) {
return warning(
"xcom2-save-deployed",
"Save mod has no save-named file",
"The save installer filters archives to files matching the XCOM 2 save-name " +
"shape (save_<name>, save<digits>). Empty output after install means the " +
"archive contained no recognisable save files, or the install path didn't " +
"complete.",
startedAt,
);
}
return passed("xcom2-save-deployed", "Save mod has at least one save-named file", startedAt);
},
),
];
Loading
Loading