diff --git a/.gitignore b/.gitignore
index 023a303d11..99a7559b38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@ dist
dist_custom
dist_portable
dist_web
+.gdl-out
+**/tests/cache
out
doc
coverage
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000..cb25232ba0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "game-description-language"]
+ path = game-description-language
+ url = https://github.com/Nexus-Mods/game-description-language.git
+ branch = main
diff --git a/extensions/games/game-xrebirth/build.mjs b/extensions/games/game-xrebirth/build.mjs
index 9af8003161..c647d46a0d 100644
--- a/extensions/games/game-xrebirth/build.mjs
+++ b/extensions/games/game-xrebirth/build.mjs
@@ -1,10 +1,9 @@
import * as path from "node:path";
-import { createConfig, bundle } from "../../../scripts/extensions-rolldown.mjs";
+import { buildGdlExtension } from "../../../scripts/build-gdl-extension.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);
+// This extension is described declaratively in game.yaml and compiled by the
+// Game Description Language (GDL) toolchain (the game-description-language
+// submodule) into a Vortex-loadable dist/index.js. The imperative bits
+// (content.xml installer, health checks) live in src/hooks.ts.
+await buildGdlExtension(path.resolve(import.meta.dirname));
diff --git a/extensions/games/game-xrebirth/game.yaml b/extensions/games/game-xrebirth/game.yaml
new file mode 100644
index 0000000000..9e534d5ba2
--- /dev/null
+++ b/extensions/games/game-xrebirth/game.yaml
@@ -0,0 +1,149 @@
+gdl: 1
+version: 1.0.1
+
+game:
+ id: xrebirth
+ name: X Rebirth
+ executable: XRebirth.exe
+ requiredFiles: [XRebirth.exe]
+ logo: gameart.webp
+ author: Black Tree Gaming Ltd.
+ nexusDomain: xrebirth
+ # All X Rebirth mods deploy under the game's `extensions` folder.
+ queryModPath: extensions
+
+stores:
+ steam: "2870"
+
+# Mod types are classification tags only: every type deploys to the same
+# `extensions` folder (matching the original extension, which set modType tags
+# but never gave them distinct paths).
+modTypes:
+ - { id: xrebirth-savegame, name: "X Rebirth - savegame", path: "${installPath}/extensions" }
+ - {
+ id: xrebirth-shader-injector,
+ name: "X Rebirth - shader injector",
+ path: "${installPath}/extensions",
+ }
+ - { id: xrebirth-utility, name: "X Rebirth - utility", path: "${installPath}/extensions" }
+ - { id: xrebirth-dropin, name: "X Rebirth - drop-in", path: "${installPath}/extensions" }
+ - { id: xrebirth-save-patch, name: "X Rebirth - save patch", path: "${installPath}/extensions" }
+ - {
+ id: xrebirth-documentation,
+ name: "X Rebirth - documentation",
+ path: "${installPath}/extensions",
+ }
+
+# Priority order (lower = tried first) mirrors the original PRIORITIES table.
+installers:
+ # 50 - canonical content.xml mod: parses the XML and emits attribute
+ # instructions, so it runs through a custom install hook.
+ - id: content-xml
+ priority: 50
+ when: { hasFile: "**/content.xml" }
+ install: { hook: installContentXml }
+
+ # 60 - save_NNN.xml / quicksave.xml at any depth (keep archive layout).
+ - id: savegame
+ priority: 60
+ when: { matches: '(?i)(^|/)(quicksave|save_\d+)\.xml$' }
+ copy: { stripCommonRoot: false }
+ modType: xrebirth-savegame
+
+ # 65 - SweetFX / ReShade shader injector markers.
+ - id: shader-injector
+ priority: 65
+ when:
+ any:
+ - { matches: '(?i)(^|/)d3d9\.dll$' }
+ - { matches: '(?i)(^|/)dxgi\.dll$' }
+ - { matches: '(?i)(^|/)d3d9\.ini$' }
+ - { matches: '(?i)(^|/)SweetFX([\\/]|_)' }
+ - { matches: "(?i)(^|/)reshade-shaders/" }
+ - { matches: "(?i)(^|/)ReShade/" }
+ copy: { stripCommonRoot: true }
+ modType: xrebirth-shader-injector
+
+ # 70 - any .exe present (standalone utility/tool).
+ - id: utility
+ priority: 70
+ when: { extensions: { list: [".exe"], mode: any } }
+ copy: { stripCommonRoot: true }
+ modType: xrebirth-utility
+
+ # 75 - looks like an X Rebirth drop-in: loose game content (archives, game
+ # subfolders, audio/video/config) that deploys into extensions/ as-is.
+ - id: dropin
+ priority: 75
+ when:
+ any:
+ - { hasFile: "**/*.cat" }
+ - { hasFile: "**/*.dat" }
+ - { hasFile: "**/*.cur" }
+ - { hasFile: "**/*.ini" }
+ - { hasFile: "**/*.{ogg,mp3,wav}" }
+ - { hasFile: "**/*.{mkv,mp4,webm}" }
+ - { hasFile: "**/t/*.xml" }
+ - { hasFile: "**/assets/**" }
+ - { hasFile: "**/libraries/**/*.xml" }
+ - { hasFile: "**/maps/**/*.xml" }
+ - { hasFile: "**/md/**/*.xml" }
+ - { hasFile: "**/cinematics/**" }
+ - { hasFile: "**/aiscripts/**/*.xml" }
+ - { hasFile: "**/voice-*/**/*.{ogg,wav}" }
+ - { hasFile: "**/ui/**" }
+ - { hasFile: "**/sfx/**" }
+ copy: { stripCommonRoot: true }
+ modType: xrebirth-dropin
+
+ # 80 - every file is .xml/.txt and at least one is .xml (XML-only save patch).
+ - id: save-patch
+ priority: 80
+ when:
+ all:
+ - { extensions: { list: [".xml", ".txt"], mode: all } }
+ - { extensions: { list: [".xml"], mode: any } }
+ copy: { stripCommonRoot: false }
+ modType: xrebirth-save-patch
+
+ # 90 - pure documentation (every file is a doc extension).
+ - id: documentation
+ priority: 90
+ when:
+ extensions:
+ list:
+ [
+ ".pdf",
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".svg",
+ ".xlsx",
+ ".xls",
+ ".docx",
+ ".doc",
+ ".odt",
+ ".ods",
+ ".md",
+ ".rtf",
+ ]
+ mode: all
+ copy: { stripCommonRoot: true }
+ modType: xrebirth-documentation
+
+# Corpus testing: run the installers against real X Rebirth mod manifests
+# fetched from Nexus (`gdl test:corpus --fetch`). When the extension is built,
+# the corpus drives the real installer chain (including the content.xml hook)
+# and health checks; syntheticContent supplies the bytes the hook reads, since
+# manifests carry only file lists.
+tests:
+ corpus: nexus
+ syntheticContent:
+ content.xml: ''
+
+# In-game health checks (registered via context.registerHealthCheck).
+diagnostics:
+ - hook: modHasFilesCheck
+ - hook: contentXmlCustomFileNameCheck
+ - hook: modShapeRecognisedCheck
diff --git a/extensions/games/game-xrebirth/assets/gameart.webp b/extensions/games/game-xrebirth/gameart.webp
similarity index 100%
rename from extensions/games/game-xrebirth/assets/gameart.webp
rename to extensions/games/game-xrebirth/gameart.webp
diff --git a/extensions/games/game-xrebirth/package.json b/extensions/games/game-xrebirth/package.json
index 079c0c19e3..5cc0dcd26e 100644
--- a/extensions/games/game-xrebirth/package.json
+++ b/extensions/games/game-xrebirth/package.json
@@ -3,8 +3,7 @@
"version": "1.0.1",
"description": "Support for X Rebirth",
"scripts": {
- "_assets": "copyfiles -u 1 -f ./assets/* dist",
- "build": "node build.mjs && pnpm run _assets && pnpm extractInfo",
+ "build": "node build.mjs",
"test": "pnpm exec vitest run"
},
"author": "Black Tree Gaming Ltd.",
@@ -14,17 +13,13 @@
"config": {
"game": "X Rebirth"
},
- "vortex": {
- "gameExtensionTest": true
- },
"dependencies": {
"xml2js": "catalog:"
},
"devDependencies": {
"@nexusmods/vortex-api": "workspace:*",
"@types/xml2js": "catalog:",
- "@vortex/extension-test-mocks": "workspace:*",
- "@vortex/game-extension-test": "workspace:*"
+ "@vortex/extension-test-mocks": "workspace:*"
},
"nx": {
"tags": [
@@ -37,16 +32,6 @@
"typescript",
"vortex-api"
]
- },
- "test:game-extensions": {
- "executor": "nx:run-commands",
- "options": {
- "command": "pnpm --filter @vortex/game-extension-test run start -- --game game-xrebirth"
- },
- "inputs": [
- "{projectRoot}/src/**/*.ts"
- ],
- "cache": false
}
}
}
diff --git a/extensions/games/game-xrebirth/src/diagnostic.test.ts b/extensions/games/game-xrebirth/src/diagnostic.test.ts
deleted file mode 100644
index 1bf6e98659..0000000000
--- a/extensions/games/game-xrebirth/src/diagnostic.test.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { describe, test, expect } from "vitest";
-
-import { healthChecks } from "./diagnostic";
-import { XREBIRTH_MOD_TYPES } from "./installers";
-
-const [modHasFilesCheck, customFileNameCheck, modShapeCheck] = healthChecks;
-
-function mod(opts: { files?: string[]; attributes?: Record }): {
- id: string;
- state: "installed";
- type: string;
- installationPath: string;
- files: string[];
- attributes: Record;
-} {
- return {
- id: "test",
- state: "installed",
- type: "",
- installationPath: "test",
- files: opts.files ?? [],
- attributes: opts.attributes ?? {},
- };
-}
-
-const ctx = {
- modId: "test",
- files: [],
- readFile: async () => Buffer.alloc(0),
- attributes: {},
-};
-
-describe("modHasFilesCheck", () => {
- test("warns when installer produced no files", async () => {
- const result = await modHasFilesCheck!.checkMod(
- {},
- { ...ctx, files: mod({ files: [] }).files },
- );
- expect(result.status).toBe("warning");
- expect(result.message).toMatch(/no files/i);
- });
-
- test("passes when there is at least one file", async () => {
- const result = await modHasFilesCheck!.checkMod({}, { ...ctx, files: ["a/b.xml"] });
- expect(result.status).toBe("passed");
- });
-});
-
-describe("contentXmlCustomFileNameCheck", () => {
- test("not applicable when not a content.xml mod", async () => {
- const result = await customFileNameCheck!.checkMod(
- {},
- { ...ctx, files: ["readme.txt"], attributes: {} },
- );
- expect(result.status).toBe("passed");
- expect(result.message).toMatch(/not applicable/i);
- });
-
- test("warns when content.xml mod is missing customFileName", async () => {
- const result = await customFileNameCheck!.checkMod(
- {},
- { ...ctx, files: ["mod/content.xml"], attributes: {} },
- );
- expect(result.status).toBe("warning");
- expect(result.message).toMatch(/missing customFileName/i);
- });
-
- test("passes when content.xml mod has customFileName", async () => {
- const result = await customFileNameCheck!.checkMod(
- {},
- {
- ...ctx,
- files: ["mod/content.xml"],
- attributes: { customFileName: "Awesome Mod" },
- },
- );
- expect(result.status).toBe("passed");
- });
-
- test("detects content.xml at any depth, case-insensitive", async () => {
- const result = await customFileNameCheck!.checkMod(
- {},
- { ...ctx, files: ["deep/path/Content.XML"], attributes: {} },
- );
- expect(result.status).toBe("warning");
- });
-});
-
-describe("modShapeRecognisedCheck", () => {
- test("recognised as content.xml mod", async () => {
- const result = await modShapeCheck!.checkMod(
- {},
- { ...ctx, files: ["a/content.xml"], attributes: {} },
- );
- expect(result.status).toBe("passed");
- expect(result.message).toMatch(/content\.xml/);
- });
-
- test("recognised by tagged modType", async () => {
- const result = await modShapeCheck!.checkMod(
- {},
- {
- ...ctx,
- files: ["tool.exe"],
- attributes: { modType: XREBIRTH_MOD_TYPES.utility },
- },
- );
- expect(result.status).toBe("passed");
- expect(result.message).toContain(XREBIRTH_MOD_TYPES.utility);
- });
-
- test("recognised by stopPattern match", async () => {
- const result = await modShapeCheck!.checkMod(
- {},
- { ...ctx, files: ["data.cat"], attributes: {} },
- );
- expect(result.status).toBe("passed");
- expect(result.message).toMatch(/stopPatterns/);
- });
-
- test("warns when nothing recognised", async () => {
- const result = await modShapeCheck!.checkMod(
- {},
- { ...ctx, files: ["random.bin"], attributes: {} },
- );
- expect(result.status).toBe("warning");
- });
-});
diff --git a/extensions/games/game-xrebirth/src/diagnostic.ts b/extensions/games/game-xrebirth/src/diagnostic.ts
deleted file mode 100644
index 8fc02e82e3..0000000000
--- a/extensions/games/game-xrebirth/src/diagnostic.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import * as path from "node:path";
-
-import { types, util } from "@nexusmods/vortex-api";
-
-import { XREBIRTH_MOD_TYPES } from "./installers";
-import { XREBIRTH_STOP_PATTERNS } from "./stopPatterns";
-
-const TAGGED_NON_CONTENT_XML = new Set([
- XREBIRTH_MOD_TYPES.savegame,
- XREBIRTH_MOD_TYPES.shaderInjector,
- XREBIRTH_MOD_TYPES.utility,
- XREBIRTH_MOD_TYPES.documentation,
- XREBIRTH_MOD_TYPES.savePatch,
-]);
-
-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;
-
-// Hoisted: the stopPatterns list is constant, so compile once instead of every
-// check invocation.
-const STOP_PATTERN_REGEXES = util.compileStopPatterns(XREBIRTH_STOP_PATTERNS);
-
-function isContentXmlMod(mod: types.IMod): boolean {
- return mod.files.some((f) => path.basename(f).toLowerCase() === "content.xml");
-}
-
-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(),
- };
-}
-
-/**
- * Fails when an install produced zero files — typically means the installer's
- * filter logic ate every entry (e.g. directory-only filter mis-applied).
- */
-const modHasFilesCheck: types.IModHealthCheck = {
- id: "xrebirth-mod-has-files",
- name: "X Rebirth — mod has files",
- description: "Verifies that the installer produced at least one file.",
- category: CATEGORY,
- severity: SEVERITY_WARNING,
- triggers: TRIGGERS,
- checkMod: async (_api, mod) => {
- const startedAt = Date.now();
- if (mod.files.length === 0) {
- return warning(
- "xrebirth-mod-has-files",
- "Installer produced no files",
- "An installer matched but emitted zero file instructions.",
- startedAt,
- );
- }
- return passed("xrebirth-mod-has-files", "Install output has at least one file", startedAt);
- },
-};
-
-/**
- * For content.xml mods: the install path always emits a `customFileName`
- * attribute from the XML's `name` field. Its absence after install indicates
- * the install function didn't run end-to-end (e.g. silent throw).
- */
-const contentXmlCustomFileNameCheck: types.IModHealthCheck = {
- id: "xrebirth-content-xml-customFileName",
- name: "X Rebirth — content.xml carries customFileName",
- description: "Verifies that content.xml mods record their declared name.",
- category: CATEGORY,
- severity: SEVERITY_WARNING,
- triggers: TRIGGERS,
- checkMod: async (_api, mod) => {
- const startedAt = Date.now();
- if (!isContentXmlMod(mod)) {
- return passed(
- "xrebirth-content-xml-customFileName",
- "Not a content.xml mod; check not applicable",
- startedAt,
- );
- }
- if (mod.attributes.customFileName === undefined) {
- return warning(
- "xrebirth-content-xml-customFileName",
- "content.xml mod missing customFileName attribute",
- "The content.xml installer always emits customFileName from the XML's name field. " +
- "Its absence means the install path didn't complete.",
- startedAt,
- );
- }
- return passed(
- "xrebirth-content-xml-customFileName",
- "content.xml mod has customFileName",
- startedAt,
- );
- },
-};
-
-/**
- * The mod must look like *some* recognisable X Rebirth shape: a content.xml
- * mod, a drop-in matching the game's stopPatterns, or one of the tagged
- * non-content modTypes (savegame, utility, shader, etc.). Otherwise the
- * installer matched something but it isn't actually X Rebirth content.
- */
-const modShapeRecognisedCheck: types.IModHealthCheck = {
- id: "xrebirth-mod-shape-recognised",
- name: "X Rebirth — mod has a recognisable shape",
- description:
- "Verifies the install output is content.xml, matches stopPatterns, or is tagged with a known modType.",
- category: CATEGORY,
- severity: SEVERITY_WARNING,
- triggers: TRIGGERS,
- checkMod: async (_api, mod) => {
- const startedAt = Date.now();
- if (isContentXmlMod(mod)) {
- return passed("xrebirth-mod-shape-recognised", "Recognised as content.xml mod", startedAt);
- }
- const modType = mod.attributes.modType as string | undefined;
- if (modType !== undefined && TAGGED_NON_CONTENT_XML.has(modType)) {
- return passed(
- "xrebirth-mod-shape-recognised",
- `Recognised by modType: ${modType}`,
- startedAt,
- );
- }
- if (mod.files.some((f) => STOP_PATTERN_REGEXES.some((re) => re.test(f)))) {
- return passed("xrebirth-mod-shape-recognised", "Recognised by stopPatterns match", startedAt);
- }
- return warning(
- "xrebirth-mod-shape-recognised",
- "Install output has no recognisable X Rebirth shape",
- "No content.xml, no stop-pattern matches, and no recognised modType.",
- startedAt,
- );
- },
-};
-
-export const healthChecks: types.IModHealthCheck[] = [
- modHasFilesCheck,
- contentXmlCustomFileNameCheck,
- modShapeRecognisedCheck,
-];
diff --git a/extensions/games/game-xrebirth/src/hooks.test.ts b/extensions/games/game-xrebirth/src/hooks.test.ts
new file mode 100644
index 0000000000..6e7f6fadae
--- /dev/null
+++ b/extensions/games/game-xrebirth/src/hooks.test.ts
@@ -0,0 +1,172 @@
+import path from "node:path";
+
+import { setReadFileResolver } from "@vortex/extension-test-mocks";
+import { describe, test, it, expect, beforeEach } from "vitest";
+
+import {
+ installContentXml,
+ modHasFilesCheck,
+ contentXmlCustomFileNameCheck,
+ modShapeRecognisedCheck,
+} from "./hooks";
+
+// ---------------------------------------------------------------------------
+// installContentXml
+// ---------------------------------------------------------------------------
+
+describe("installContentXml", () => {
+ beforeEach(() => {
+ setReadFileResolver(async () => Buffer.alloc(0));
+ });
+
+ it("emits attribute + copy instructions for a well-formed content.xml", async () => {
+ setReadFileResolver(
+ async () =>
+ '' +
+ '',
+ );
+
+ const result = await installContentXml(
+ ["wrap/content.xml", "wrap/data.cat", "wrap/dir/"],
+ "/install/dest",
+ );
+
+ expect(result.instructions).toContainEqual({
+ type: "attribute",
+ key: "customFileName",
+ value: "My Mod",
+ });
+ expect(result.instructions).toContainEqual({ type: "attribute", key: "sticky", value: true });
+ expect(result.instructions).toContainEqual({ type: "attribute", key: "author", value: "me" });
+
+ const copyInstructions = result.instructions.filter((i) => i.type === "copy");
+ expect(copyInstructions).toEqual([
+ { type: "copy", source: "wrap/content.xml", destination: path.join("my-mod", "content.xml") },
+ { type: "copy", source: "wrap/data.cat", destination: path.join("my-mod", "data.cat") },
+ ]);
+ });
+
+ it("throws DataInvalid for malformed XML", async () => {
+ setReadFileResolver(async () => "<<>>");
+ await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({
+ name: "DataInvalid",
+ });
+ });
+
+ it("throws DataInvalid when content.xml has no id attribute", async () => {
+ setReadFileResolver(async () => '');
+ await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({
+ name: "DataInvalid",
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// health checks
+// ---------------------------------------------------------------------------
+
+const ctx = {
+ modId: "test",
+ files: [] as string[],
+ readFile: async () => Buffer.alloc(0),
+ attributes: {} as Record,
+};
+
+// The health checks ignore the api argument; derive its type from checkMod so
+// the unused stub stays correctly typed.
+const STUB_API = {} as unknown as Parameters[0];
+
+describe("modHasFilesCheck", () => {
+ test("warns when installer produced no files", async () => {
+ const result = await modHasFilesCheck.checkMod(STUB_API, { ...ctx, files: [] });
+ expect(result.status).toBe("warning");
+ expect(result.message).toMatch(/no files/i);
+ });
+
+ test("passes when there is at least one file", async () => {
+ const result = await modHasFilesCheck.checkMod(STUB_API, { ...ctx, files: ["a/b.xml"] });
+ expect(result.status).toBe("passed");
+ });
+});
+
+describe("contentXmlCustomFileNameCheck", () => {
+ test("not applicable when not a content.xml mod", async () => {
+ const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["readme.txt"],
+ attributes: {},
+ });
+ expect(result.status).toBe("passed");
+ expect(result.message).toMatch(/not applicable/i);
+ });
+
+ test("warns when content.xml mod is missing customFileName", async () => {
+ const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["mod/content.xml"],
+ attributes: {},
+ });
+ expect(result.status).toBe("warning");
+ expect(result.message).toMatch(/missing customFileName/i);
+ });
+
+ test("passes when content.xml mod has customFileName", async () => {
+ const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["mod/content.xml"],
+ attributes: { customFileName: "Awesome Mod" },
+ });
+ expect(result.status).toBe("passed");
+ });
+
+ test("detects content.xml at any depth, case-insensitive", async () => {
+ const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["deep/path/Content.XML"],
+ attributes: {},
+ });
+ expect(result.status).toBe("warning");
+ });
+});
+
+describe("modShapeRecognisedCheck", () => {
+ test("recognised as content.xml mod", async () => {
+ const result = await modShapeRecognisedCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["a/content.xml"],
+ attributes: {},
+ });
+ expect(result.status).toBe("passed");
+ expect(result.message).toMatch(/content\.xml/);
+ });
+
+ test("recognised by tagged modType", async () => {
+ const result = await modShapeRecognisedCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["tool.exe"],
+ attributes: { modType: "xrebirth-utility" },
+ });
+ expect(result.status).toBe("passed");
+ expect(result.message).toContain("xrebirth-utility");
+ });
+
+ test("recognised by drop-in modType", async () => {
+ const result = await modShapeRecognisedCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["data.cat"],
+ attributes: { modType: "xrebirth-dropin" },
+ });
+ expect(result.status).toBe("passed");
+ expect(result.message).toContain("xrebirth-dropin");
+ });
+
+ test("warns when neither content.xml nor a recognised modType", async () => {
+ const result = await modShapeRecognisedCheck.checkMod(STUB_API, {
+ ...ctx,
+ files: ["random.bin"],
+ attributes: {},
+ });
+ expect(result.status).toBe("warning");
+ });
+});
diff --git a/extensions/games/game-xrebirth/src/hooks.ts b/extensions/games/game-xrebirth/src/hooks.ts
new file mode 100644
index 0000000000..dc9731adbe
--- /dev/null
+++ b/extensions/games/game-xrebirth/src/hooks.ts
@@ -0,0 +1,242 @@
+/**
+ * Imperative hooks for the X Rebirth GDL extension. The declarative parts of
+ * the extension live in game.yaml; this file holds the two things GDL can't
+ * express declaratively:
+ *
+ * - installContentXml: the canonical content.xml installer, which parses the
+ * XML and emits attribute instructions. Referenced by game.yaml's
+ * `install: { hook: installContentXml }`.
+ * - the three in-game health checks, referenced by game.yaml's `diagnostics:`
+ * block and registered via context.registerHealthCheck.
+ */
+import path from "path";
+
+import { fs, types, util } from "@nexusmods/vortex-api";
+import { parseStringPromise } from "xml2js";
+
+/** Mod type ids, mirrored from game.yaml's modTypes block. */
+const XREBIRTH_MOD_TYPES = {
+ savegame: "xrebirth-savegame",
+ shaderInjector: "xrebirth-shader-injector",
+ utility: "xrebirth-utility",
+ dropIn: "xrebirth-dropin",
+ savePatch: "xrebirth-save-patch",
+ documentation: "xrebirth-documentation",
+} as const;
+
+// ---------------------------------------------------------------------------
+// content.xml installer
+// ---------------------------------------------------------------------------
+
+/**
+ * Custom install hook for the canonical content.xml mod shape. Parses the XML
+ * at install time and emits attribute instructions (customFileName, etc.) plus
+ * copy instructions, deploying the mod under `extensions/`.
+ *
+ * GDL gates support via the `when: { hasFile: "**\/content.xml" }` predicate, so
+ * this only runs when a content.xml is present.
+ */
+export async function installContentXml(
+ files: string[],
+ destinationPath: string,
+): Promise {
+ // Match case-insensitively to agree with the `hasFile: "**/content.xml"`
+ // predicate that gates this installer (GDL globs are case-insensitive); a
+ // case-sensitive lookup here would miss e.g. `Content.XML` and crash.
+ const contentPath = files.find((file) => path.basename(file).toLowerCase() === "content.xml")!;
+ const basePath = path.dirname(contentPath);
+
+ const data = await fs.readFileAsync(path.join(destinationPath, contentPath), {
+ encoding: "utf8",
+ });
+
+ let parsed: Record;
+ try {
+ parsed = await parseStringPromise(data);
+ } catch (err) {
+ throw new util.DataInvalid("content.xml invalid: " + err.message);
+ }
+
+ const attrs = (parsed?.content as Record)?.$ as
+ | Record
+ | undefined;
+
+ const outputPath = attrs?.id;
+ if (outputPath === undefined) {
+ throw new util.DataInvalid("invalid or unsupported content.xml");
+ }
+
+ const attrInstructions: types.IInstruction[] = Object.entries({
+ customFileName: attrs?.name?.trim(),
+ description: attrs?.description,
+ sticky: attrs?.save === "true",
+ author: attrs?.author,
+ version: attrs?.version,
+ }).map(([key, value]) => ({ type: "attribute" as const, key, value }));
+
+ // Archive entries may arrive with `/` or `\` separators depending on the
+ // extraction backend; accept either when matching the wrapping dir.
+ const isDir = (f: string): boolean => f.endsWith("/") || f.endsWith("\\");
+ const isUnderBase = (f: string): boolean => {
+ if (!f.startsWith(basePath)) return false;
+ const sep = f.charAt(basePath.length);
+ return sep === "/" || sep === "\\";
+ };
+ const copyInstructions: types.IInstruction[] = files
+ .filter((file) => isUnderBase(file) && !isDir(file))
+ .map((file) => ({
+ type: "copy" as const,
+ source: file,
+ destination: path.join(outputPath, file.substring(basePath.length + 1)),
+ }));
+
+ return { instructions: attrInstructions.concat(copyInstructions) };
+}
+
+// ---------------------------------------------------------------------------
+// health checks
+// ---------------------------------------------------------------------------
+
+// Every mod our installers produce is tagged with one of these modTypes (the
+// content.xml installer is the exception and is recognised separately). The
+// mod-shape health check uses this set to confirm an installed mod has a
+// recognised X Rebirth shape.
+const RECOGNISED_MOD_TYPES = new Set(Object.values(XREBIRTH_MOD_TYPES));
+
+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 isContentXmlMod(mod: types.IModCheckContext): boolean {
+ return mod.files.some((f) => path.basename(f).toLowerCase() === "content.xml");
+}
+
+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(),
+ };
+}
+
+/**
+ * Fails when an install produced zero files - typically means the installer's
+ * filter logic ate every entry (e.g. directory-only filter mis-applied).
+ */
+export const modHasFilesCheck: types.IModHealthCheck = {
+ id: "xrebirth-mod-has-files",
+ name: "X Rebirth - mod has files",
+ description: "Verifies that the installer produced at least one file.",
+ category: CATEGORY,
+ severity: SEVERITY_WARNING,
+ triggers: TRIGGERS,
+ checkMod: async (_api, mod) => {
+ const startedAt = Date.now();
+ if (mod.files.length === 0) {
+ return warning(
+ "xrebirth-mod-has-files",
+ "Installer produced no files",
+ "An installer matched but emitted zero file instructions.",
+ startedAt,
+ );
+ }
+ return passed("xrebirth-mod-has-files", "Install output has at least one file", startedAt);
+ },
+};
+
+/**
+ * For content.xml mods: the install path always emits a `customFileName`
+ * attribute from the XML's `name` field. Its absence after install indicates
+ * the install function didn't run end-to-end (e.g. silent throw).
+ */
+export const contentXmlCustomFileNameCheck: types.IModHealthCheck = {
+ id: "xrebirth-content-xml-customFileName",
+ name: "X Rebirth - content.xml carries customFileName",
+ description: "Verifies that content.xml mods record their declared name.",
+ category: CATEGORY,
+ severity: SEVERITY_WARNING,
+ triggers: TRIGGERS,
+ checkMod: async (_api, mod) => {
+ const startedAt = Date.now();
+ if (!isContentXmlMod(mod)) {
+ return passed(
+ "xrebirth-content-xml-customFileName",
+ "Not a content.xml mod; check not applicable",
+ startedAt,
+ );
+ }
+ if (mod.attributes.customFileName === undefined) {
+ return warning(
+ "xrebirth-content-xml-customFileName",
+ "content.xml mod missing customFileName attribute",
+ "The content.xml installer always emits customFileName from the XML's name field. " +
+ "Its absence means the install path didn't complete.",
+ startedAt,
+ );
+ }
+ return passed(
+ "xrebirth-content-xml-customFileName",
+ "content.xml mod has customFileName",
+ startedAt,
+ );
+ },
+};
+
+/**
+ * The mod must look like *some* recognisable X Rebirth shape: a content.xml
+ * mod, or one tagged with a known modType (savegame, drop-in, utility, shader,
+ * etc.). Otherwise the installer matched something that isn't X Rebirth content.
+ */
+export const modShapeRecognisedCheck: types.IModHealthCheck = {
+ id: "xrebirth-mod-shape-recognised",
+ name: "X Rebirth - mod has a recognisable shape",
+ description:
+ "Verifies the install output is a content.xml mod or is tagged with a known modType.",
+ category: CATEGORY,
+ severity: SEVERITY_WARNING,
+ triggers: TRIGGERS,
+ checkMod: async (_api, mod) => {
+ const startedAt = Date.now();
+ if (isContentXmlMod(mod)) {
+ return passed("xrebirth-mod-shape-recognised", "Recognised as content.xml mod", startedAt);
+ }
+ const modType = mod.attributes.modType as string | undefined;
+ if (modType !== undefined && RECOGNISED_MOD_TYPES.has(modType)) {
+ return passed(
+ "xrebirth-mod-shape-recognised",
+ `Recognised by modType: ${modType}`,
+ startedAt,
+ );
+ }
+ return warning(
+ "xrebirth-mod-shape-recognised",
+ "Install output has no recognisable X Rebirth shape",
+ "No content.xml and no recognised modType.",
+ startedAt,
+ );
+ },
+};
diff --git a/extensions/games/game-xrebirth/src/index.js b/extensions/games/game-xrebirth/src/index.js
deleted file mode 100644
index 962175a89b..0000000000
--- a/extensions/games/game-xrebirth/src/index.js
+++ /dev/null
@@ -1,117 +0,0 @@
-const Promise = require("bluebird");
-const { parseStringPromise } = require("xml2js");
-const path = require("path");
-const { fs, log, util } = require("@nexusmods/vortex-api");
-
-function findGame() {
- return util.steam.findByName("X Rebirth").then((game) => game.gamePath);
-}
-
-function testSupported(files, gameId) {
- if (gameId !== "xrebirth") {
- return Promise.resolve({ supported: false });
- }
-
- const contentPath = files.find((file) => path.basename(file) === "content.xml");
- return Promise.resolve({
- supported: contentPath !== undefined,
- requiredFiles: [contentPath],
- });
-}
-
-function install(files, destinationPath, gameId, progressDelegate) {
- const contentPath = files.find((file) => path.basename(file) === "content.xml");
- const basePath = path.dirname(contentPath);
-
- let outputPath = basePath;
-
- return fs
- .readFileAsync(path.join(destinationPath, contentPath), { encoding: "utf8" })
- .then(async (data) => {
- let parsed;
- try {
- parsed = await parseStringPromise(data);
- } catch (err) {
- return Promise.reject(new util.DataInvalid("content.xml invalid: " + err.message));
- }
- const attrInstructions = [];
-
- const getAttr = (key) => {
- try {
- return parsed?.content?.$?.[key];
- } catch (err) {
- log("info", "attribute missing in content.xml", { key });
- }
- };
-
- outputPath = getAttr("id");
- if (outputPath === undefined) {
- return Promise.reject(new util.DataInvalid("invalid or unsupported content.xml"));
- }
- attrInstructions.push({
- type: "attribute",
- key: "customFileName",
- value: getAttr("name").trim(),
- });
- attrInstructions.push({
- type: "attribute",
- key: "description",
- value: getAttr("description"),
- });
- attrInstructions.push({
- type: "attribute",
- key: "sticky",
- value: getAttr("save") === "true",
- });
- attrInstructions.push({
- trype: "attribute",
- key: "author",
- value: getAttr("author"),
- });
- attrInstructions.push({
- type: "attribute",
- key: "version",
- value: getAttr("version"),
- });
- return Promise.resolve(attrInstructions);
- })
- .then((attrInstructions) => {
- let instructions = attrInstructions.concat(
- files
- .filter((file) => file.startsWith(basePath + path.sep) && !file.endsWith(path.sep))
- .map((file) => ({
- type: "copy",
- source: file,
- destination: path.join(outputPath, file.substring(basePath.length + 1)),
- })),
- );
- return { instructions };
- });
-}
-
-function main(context) {
- context.registerGame({
- id: "xrebirth",
- name: "X Rebirth",
- mergeMods: true,
- queryPath: findGame,
- queryModPath: () => "extensions",
- logo: "gameart.jpg",
- executable: () => "XRebirth.exe",
- requiredFiles: ["XRebirth.exe"],
- environment: {
- SteamAPPId: "2870",
- },
- details: {
- steamAppId: 2870,
- },
- });
-
- context.registerInstaller("xrebirth", 50, testSupported, install);
-
- return true;
-}
-
-module.exports = {
- default: main,
-};
diff --git a/extensions/games/game-xrebirth/src/index.ts b/extensions/games/game-xrebirth/src/index.ts
deleted file mode 100644
index 9d65633fd2..0000000000
--- a/extensions/games/game-xrebirth/src/index.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { util } from "@nexusmods/vortex-api";
-import type { types } from "@nexusmods/vortex-api";
-
-import { healthChecks } from "./diagnostic";
-import {
- XREBIRTH_CONTENT_XML_PRIORITY,
- XREBIRTH_GAME_ID,
- XREBIRTH_INSTALLER_SPECS,
- installContentXml,
- testContentXml,
-} from "./installers";
-import { XREBIRTH_STOP_PATTERNS } from "./stopPatterns";
-
-function main(context: types.IExtensionContext): boolean {
- context.registerGame({
- id: XREBIRTH_GAME_ID,
- name: "X Rebirth",
- queryArgs: { steam: "2870" },
- queryModPath: () => "extensions",
- logo: "gameart.webp",
- executable: () => "XRebirth.exe",
- requiredFiles: ["XRebirth.exe"],
- details: { stopPatterns: XREBIRTH_STOP_PATTERNS },
- });
-
- // The canonical content.xml installer is hand-written: it parses XML and
- // emits attribute instructions, which the declarative table can't express.
- context.registerInstaller(
- XREBIRTH_GAME_ID,
- XREBIRTH_CONTENT_XML_PRIORITY,
- testContentXml,
- installContentXml,
- );
-
- // Everything else is a config-driven match → copy → setmodtype.
- util.declareInstallers(context, XREBIRTH_GAME_ID, XREBIRTH_INSTALLER_SPECS);
-
- for (const check of healthChecks) {
- context.registerHealthCheck(check);
- }
-
- return true;
-}
-
-export default main;
diff --git a/extensions/games/game-xrebirth/src/installers.test.ts b/extensions/games/game-xrebirth/src/installers.test.ts
deleted file mode 100644
index b03215b9db..0000000000
--- a/extensions/games/game-xrebirth/src/installers.test.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import path from "node:path";
-
-import { setReadFileResolver } from "@vortex/extension-test-mocks";
-import { describe, expect, it, beforeEach } from "vitest";
-
-import {
- XREBIRTH_GAME_ID,
- XREBIRTH_INSTALLER_SPECS,
- XREBIRTH_MOD_TYPES,
- installContentXml,
- testContentXml,
-} from "./installers";
-
-function specById(id: string) {
- const spec = XREBIRTH_INSTALLER_SPECS.find((s) => s.id === id);
- if (!spec) throw new Error(`no spec with id=${id}`);
- return spec;
-}
-
-// Mirror of installerHelpers.ts evaluateMatch — kept tiny on purpose so the
-// tests exercise each spec's match config without depending on the renderer
-// runtime. `stopPatterns` is excluded because it's delegated to the framework
-// and validated by installerHelpers.test.ts.
-function evaluateMatch(spec: ReturnType, files: string[]): boolean {
- const dataFiles = files.filter((f) => !f.endsWith("/") && !f.endsWith("\\"));
- const match = spec.match;
- switch (match.kind) {
- case "extensions": {
- const lower = match.list.map((e) => e.toLowerCase());
- const test = (f: string) => lower.some((ext) => f.toLowerCase().endsWith(ext));
- return match.mode === "all" ? dataFiles.every(test) : dataFiles.some(test);
- }
- case "regex": {
- const test = (f: string) => match.patterns.some((re) => re.test(f));
- return match.mode === "all" ? dataFiles.every(test) : dataFiles.some(test);
- }
- case "custom":
- return match.predicate(files);
- default:
- throw new Error(`unexpected match kind ${(match as { kind: string }).kind}`);
- }
-}
-
-describe("XREBIRTH_INSTALLER_SPECS — shape", () => {
- it("declares six specs in ascending priority order", () => {
- expect(XREBIRTH_INSTALLER_SPECS.map((s) => s.id)).toEqual([
- "savegame",
- "shader-injector",
- "utility",
- "dropin",
- "save-patch",
- "documentation",
- ]);
- const priorities = XREBIRTH_INSTALLER_SPECS.map((s) => s.priority);
- expect(priorities).toEqual([...priorities].sort((a, b) => a - b));
- });
-
- it("every spec emits a setmodtype from XREBIRTH_MOD_TYPES", () => {
- const declared = new Set(Object.values(XREBIRTH_MOD_TYPES));
- for (const spec of XREBIRTH_INSTALLER_SPECS) {
- expect(spec.modType).toBeDefined();
- expect(declared.has(spec.modType as never)).toBe(true);
- }
- });
-});
-
-describe("XREBIRTH_INSTALLER_SPECS — savegame", () => {
- const spec = specById("savegame");
-
- it.each([["save_001.xml"], ["save_999.xml"], ["quicksave.xml"], ["nested/save_42.xml"]])(
- "matches %s",
- (file) => {
- expect(evaluateMatch(spec, [file])).toBe(true);
- },
- );
-
- it.each([
- ["content.xml"],
- ["save_001.txt"],
- ["notasave_001.xml"],
- ["save.xml"],
- ["savefoo_1.xml"],
- ])("rejects %s", (file) => {
- expect(evaluateMatch(spec, [file])).toBe(false);
- });
-});
-
-describe("XREBIRTH_INSTALLER_SPECS — shader-injector", () => {
- const spec = specById("shader-injector");
-
- it.each([
- ["d3d9.dll"],
- ["dxgi.dll"],
- ["d3d9.ini"],
- ["SweetFX/Shaders/foo.fx"],
- ["SweetFX_settings.txt"],
- ["reshade-shaders/Shaders/SMAA.fx"],
- ["ReShade/foo.ini"],
- ])("matches %s", (file) => {
- expect(evaluateMatch(spec, [file])).toBe(true);
- });
-
- it.each([["readme.txt"], ["content.xml"], ["bin/Game.exe"]])("rejects %s", (file) => {
- expect(evaluateMatch(spec, [file])).toBe(false);
- });
-});
-
-describe("XREBIRTH_INSTALLER_SPECS — utility", () => {
- const spec = specById("utility");
-
- it("matches archives containing any .exe", () => {
- expect(evaluateMatch(spec, ["tool.exe", "readme.txt"])).toBe(true);
- expect(evaluateMatch(spec, ["nested/dir/tool.EXE"])).toBe(true);
- });
-
- it("rejects archives without an .exe", () => {
- expect(evaluateMatch(spec, ["content.xml", "data.cat"])).toBe(false);
- });
-});
-
-describe("XREBIRTH_INSTALLER_SPECS — dropin", () => {
- const spec = specById("dropin");
-
- it("uses the stopPatterns matcher (delegated to framework)", () => {
- expect(spec.match.kind).toBe("stopPatterns");
- expect(spec.install.stripCommonRoot).toBe(true);
- });
-});
-
-describe("XREBIRTH_INSTALLER_SPECS — save-patch", () => {
- const spec = specById("save-patch");
-
- it("accepts archives with only .xml + .txt and at least one .xml", () => {
- expect(evaluateMatch(spec, ["patch.xml", "notes.txt"])).toBe(true);
- expect(evaluateMatch(spec, ["a.xml", "b.xml"])).toBe(true);
- });
-
- it("rejects archives missing an .xml file", () => {
- expect(evaluateMatch(spec, ["a.txt", "b.txt"])).toBe(false);
- });
-
- it("rejects archives with non-xml/txt files", () => {
- expect(evaluateMatch(spec, ["a.xml", "b.dat"])).toBe(false);
- });
-
- it("rejects empty archives", () => {
- expect(evaluateMatch(spec, [])).toBe(false);
- });
-
- it("ignores directory entries when judging emptiness", () => {
- expect(evaluateMatch(spec, ["dir/"])).toBe(false);
- });
-});
-
-describe("XREBIRTH_INSTALLER_SPECS — documentation", () => {
- const spec = specById("documentation");
-
- it("accepts archives where every file is a doc extension", () => {
- expect(evaluateMatch(spec, ["readme.md", "screenshots/preview.png"])).toBe(true);
- });
-
- it("rejects archives with any non-doc file", () => {
- expect(evaluateMatch(spec, ["readme.md", "patch.xml"])).toBe(false);
- });
-});
-
-describe("testContentXml", () => {
- it("returns supported=false for wrong gameId", async () => {
- const result = await testContentXml(["content.xml"], "skyrim");
- expect(result).toEqual({ supported: false, requiredFiles: [] });
- });
-
- it("returns supported=true with content.xml at the root", async () => {
- const result = await testContentXml(["content.xml", "extras/x.txt"], XREBIRTH_GAME_ID);
- expect(result.supported).toBe(true);
- expect(result.requiredFiles).toEqual(["content.xml"]);
- });
-
- it("finds a nested content.xml", async () => {
- const result = await testContentXml(
- ["wrap/mod/content.xml", "wrap/mod/data.cat"],
- XREBIRTH_GAME_ID,
- );
- expect(result.supported).toBe(true);
- expect(result.requiredFiles).toEqual(["wrap/mod/content.xml"]);
- });
-
- it("rejects archives without a content.xml", async () => {
- const result = await testContentXml(["data.cat", "data.dat"], XREBIRTH_GAME_ID);
- expect(result).toEqual({ supported: false, requiredFiles: [] });
- });
-});
-
-describe("installContentXml", () => {
- beforeEach(() => {
- setReadFileResolver(async () => Buffer.alloc(0));
- });
-
- it("emits attribute + copy instructions for a well-formed content.xml", async () => {
- setReadFileResolver(
- async () =>
- '' +
- '',
- );
-
- const result = await installContentXml(
- ["wrap/content.xml", "wrap/data.cat", "wrap/dir/"],
- "/install/dest",
- );
-
- expect(result.instructions).toContainEqual({
- type: "attribute",
- key: "customFileName",
- value: "My Mod",
- });
- expect(result.instructions).toContainEqual({ type: "attribute", key: "sticky", value: true });
- expect(result.instructions).toContainEqual({ type: "attribute", key: "author", value: "me" });
-
- const copyInstructions = result.instructions.filter((i) => i.type === "copy");
- expect(copyInstructions).toEqual([
- {
- type: "copy",
- source: "wrap/content.xml",
- destination: path.join("my-mod", "content.xml"),
- },
- {
- type: "copy",
- source: "wrap/data.cat",
- destination: path.join("my-mod", "data.cat"),
- },
- ]);
- });
-
- it("throws DataInvalid for malformed XML", async () => {
- setReadFileResolver(async () => "<<>>");
- await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({
- name: "DataInvalid",
- });
- });
-
- it("throws DataInvalid when content.xml has no id attribute", async () => {
- setReadFileResolver(async () => '');
- await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({
- name: "DataInvalid",
- });
- });
-});
diff --git a/extensions/games/game-xrebirth/src/installers.ts b/extensions/games/game-xrebirth/src/installers.ts
deleted file mode 100644
index de18adf78e..0000000000
--- a/extensions/games/game-xrebirth/src/installers.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import path from "path";
-
-import { fs, util } from "@nexusmods/vortex-api";
-import type { types } from "@nexusmods/vortex-api";
-import { parseStringPromise } from "xml2js";
-
-export const XREBIRTH_GAME_ID = "xrebirth";
-
-/**
- * Single source of truth for modType IDs registered by this extension. Mirrored
- * by the installer ids in the spec table below so deployment can route on
- * modType without string drift.
- */
-export const XREBIRTH_MOD_TYPES = {
- savegame: "xrebirth-savegame",
- shaderInjector: "xrebirth-shader-injector",
- utility: "xrebirth-utility",
- dropIn: "xrebirth-dropin",
- savePatch: "xrebirth-save-patch",
- documentation: "xrebirth-documentation",
-} as const;
-
-/**
- * Installer priority slots. Vortex dispatches lower numbers first, so the
- * most specific match must have the lowest number. The ordering rationale:
- *
- * contentXml (50) — canonical X Rebirth mod shape; XML-driven and
- * emits attribute instructions. Always tried first.
- * savegame (60) — narrow regex (save_NNN.xml / quicksave.xml) that
- * wouldn't accidentally swallow other XML mods.
- * shaderInjector(65) — d3d9/dxgi/SweetFX/ReShade markers; specific enough
- * to beat the generic .exe utility match.
- * utility (70) — .exe presence; broad, but more specific than the
- * stopPattern drop-in fallback.
- * dropIn (75) — game-shape match via the registered stopPatterns;
- * covers everything that "looks like an X Rebirth
- * drop-in" without an .exe or shader marker.
- * savePatch (80) — runs after dropIn because XML/TXT-only archives
- * that ALSO match a stop-pattern (rare but possible)
- * should deploy as a drop-in instead.
- * documentation (90) — pure-docs catch-all; only fires when no installer
- * above accepts the archive.
- */
-const PRIORITIES = {
- contentXml: 50,
- savegame: 60,
- shaderInjector: 65,
- utility: 70,
- dropIn: 75,
- savePatch: 80,
- documentation: 90,
-} as const;
-
-/** SweetFX / ReShade shader-injector signatures. */
-const SHADER_MARKERS = [
- /(^|\/)d3d9\.dll$/i,
- /(^|\/)dxgi\.dll$/i,
- /(^|\/)d3d9\.ini$/i,
- /(^|\/)SweetFX([\\/]|_)/i,
- /(^|\/)reshade-shaders\//i,
- /(^|\/)ReShade\//i,
-];
-
-/** Matches `save_NNN.xml` and `quicksave.xml` filenames at any depth. */
-const SAVE_FILE_RE = /(^|\/)(quicksave|save_\d+)\.xml$/i;
-
-const DOC_EXTENSIONS = [
- ".pdf",
- ".png",
- ".jpg",
- ".jpeg",
- ".gif",
- ".svg",
- ".xlsx",
- ".xls",
- ".docx",
- ".doc",
- ".odt",
- ".ods",
- ".md",
- ".rtf",
-];
-
-/**
- * Declarative installer table for X Rebirth. Priority ordering is documented
- * on the PRIORITIES constant above.
- */
-export const XREBIRTH_INSTALLER_SPECS: types.IInstallerSpec[] = [
- {
- id: "savegame",
- priority: PRIORITIES.savegame,
- modType: XREBIRTH_MOD_TYPES.savegame,
- match: { kind: "regex", patterns: [SAVE_FILE_RE], mode: "any" },
- install: { stripCommonRoot: false },
- },
- {
- id: "shader-injector",
- priority: PRIORITIES.shaderInjector,
- modType: XREBIRTH_MOD_TYPES.shaderInjector,
- match: { kind: "regex", patterns: SHADER_MARKERS, mode: "any" },
- install: { stripCommonRoot: true },
- },
- {
- id: "utility",
- priority: PRIORITIES.utility,
- modType: XREBIRTH_MOD_TYPES.utility,
- match: { kind: "extensions", list: [".exe"], mode: "any" },
- install: { stripCommonRoot: true },
- },
- {
- id: "dropin",
- priority: PRIORITIES.dropIn,
- modType: XREBIRTH_MOD_TYPES.dropIn,
- // Files match one of the game's `details.stopPatterns` — translations under
- // `t/`, `.cat`/`.dat` archives, voice packs, etc.
- match: { kind: "stopPatterns" },
- install: { stripCommonRoot: true },
- },
- {
- id: "save-patch",
- priority: PRIORITIES.savePatch,
- modType: XREBIRTH_MOD_TYPES.savePatch,
- // Every file is .xml or .txt and at least one is .xml. Custom predicate
- // because the "all of these AND at least one of those" shape doesn't fit
- // the simpler match kinds.
- match: {
- kind: "custom",
- predicate: (files: string[]): boolean => {
- const data = files.filter((f) => !f.endsWith(path.sep));
- return (
- data.length > 0 &&
- data.every((f) => /\.(xml|txt)$/i.test(f)) &&
- data.some((f) => /\.xml$/i.test(f))
- );
- },
- },
- install: { stripCommonRoot: false },
- },
- {
- id: "documentation",
- priority: PRIORITIES.documentation,
- modType: XREBIRTH_MOD_TYPES.documentation,
- match: { kind: "extensions", list: DOC_EXTENSIONS, mode: "all" },
- install: { stripCommonRoot: true },
- },
-];
-
-/**
- * `testSupported` for the canonical content.xml installer. Parses the XML at
- * install time, so it stays hand-written rather than going through the
- * declarative spec table.
- */
-export function testContentXml(files: string[], gameId: string): Promise {
- if (gameId !== XREBIRTH_GAME_ID) {
- return Promise.resolve({ supported: false, requiredFiles: [] });
- }
- const contentPath = files.find((file) => path.basename(file) === "content.xml");
- return Promise.resolve({
- supported: contentPath !== undefined,
- requiredFiles: contentPath !== undefined ? [contentPath] : [],
- });
-}
-
-export async function installContentXml(
- files: string[],
- destinationPath: string,
-): Promise {
- const contentPath = files.find((file) => path.basename(file) === "content.xml")!;
- const basePath = path.dirname(contentPath);
-
- const data = await fs.readFileAsync(path.join(destinationPath, contentPath), {
- encoding: "utf8",
- });
-
- let parsed: Record;
- try {
- parsed = await parseStringPromise(data);
- } catch (err) {
- throw new util.DataInvalid("content.xml invalid: " + err.message);
- }
-
- const attrs = (parsed?.content as Record)?.$ as
- | Record
- | undefined;
-
- const outputPath = attrs?.id;
- if (outputPath === undefined) {
- throw new util.DataInvalid("invalid or unsupported content.xml");
- }
-
- const attrInstructions: types.IInstruction[] = Object.entries({
- customFileName: attrs?.name?.trim(),
- description: attrs?.description,
- sticky: attrs?.save === "true",
- author: attrs?.author,
- version: attrs?.version,
- }).map(([key, value]) => ({ type: "attribute" as const, key, value }));
-
- // Archive entries may arrive with `/` or `\` separators depending on the
- // extraction backend; accept either when matching the wrapping dir.
- const isDir = (f: string): boolean => f.endsWith("/") || f.endsWith("\\");
- const isUnderBase = (f: string): boolean => {
- if (!f.startsWith(basePath)) return false;
- const sep = f.charAt(basePath.length);
- return sep === "/" || sep === "\\";
- };
- const copyInstructions: types.IInstruction[] = files
- .filter((file) => isUnderBase(file) && !isDir(file))
- .map((file) => ({
- type: "copy" as const,
- source: file,
- destination: path.join(outputPath, file.substring(basePath.length + 1)),
- }));
-
- return { instructions: attrInstructions.concat(copyInstructions) };
-}
-
-/** Registration priority of the content.xml installer. */
-export const XREBIRTH_CONTENT_XML_PRIORITY = PRIORITIES.contentXml;
diff --git a/extensions/games/game-xrebirth/src/stopPatterns.ts b/extensions/games/game-xrebirth/src/stopPatterns.ts
deleted file mode 100644
index 835783593b..0000000000
--- a/extensions/games/game-xrebirth/src/stopPatterns.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * Regex patterns that identify X Rebirth drop-in mod files (archives meant to
- * be extracted into the game root rather than wrapped under
- * `extensions//content.xml`).
- *
- * Also passed to `IGame.details.stopPatterns` so the renderer's `getStopPatterns`
- * helper (`installer_fomod_shared/utils/gameSupport.ts:526`) returns them for
- * any FOMOD-aware installer that might inspect this game in the future.
- *
- * Patterns are compiled case-insensitive (see `util.compileStopPatterns`) so
- * `.cat` matches `.CAT`. Some entries deliberately overlap with the more
- * specific installer matchers in `installers.ts`:
- * - the broad `*.ini` rule overlaps with the shader-injector's `d3d9.ini`
- * - `ui/.+` / `assets/.+` overlap with the pure-docs `.pdf` / `.md` rule
- * In every overlap, the more specific installer (lower priority number) wins.
- * See PRIORITIES in installers.ts for the dispatch order.
- */
-export const XREBIRTH_STOP_PATTERNS: string[] = [
- // X Rebirth game-data archives.
- "[^/]*\\.cat$",
- "[^/]*\\.dat$",
- // Translation/text files.
- "(^|/)t/[^/]+\\.xml$",
- // Game language data dropped at root.
- "(^|/)lang\\.dat$",
- // Standard X Rebirth mod subfolders containing .xml content.
- "(^|/)assets/.+",
- "(^|/)libraries/.+\\.xml$",
- "(^|/)maps/.+\\.xml$",
- "(^|/)md/.+\\.xml$",
- "(^|/)cinematics/.+",
- // AI script overrides.
- "(^|/)aiscripts/.+\\.xml$",
- // Voice/audio packs (folders typically named voice-L0NN/).
- "(^|/)voice-[^/]+/.+\\.(ogg|wav)$",
- // UI presentation content.
- "(^|/)ui/.+",
- // SFX / pre-baked audio dropped at root.
- "(^|/)sfx/.+",
- // Cursor replacements.
- "[^/]*\\.cur$",
- // Audio replacements (numbered .ogg files, music .mp3, voice .wav).
- "[^/]*\\.(ogg|mp3|wav)$",
- // Video replacements (intro videos, cutscenes).
- "[^/]*\\.(mkv|mp4|webm)$",
- // Standalone configs (ReShade presets, etc.) — accepted as drop-ins.
- "[^/]*\\.ini$",
-];
diff --git a/extensions/games/game-xrebirth/src/test-descriptor.ts b/extensions/games/game-xrebirth/src/test-descriptor.ts
deleted file mode 100644
index fd39ca1db7..0000000000
--- a/extensions/games/game-xrebirth/src/test-descriptor.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Per-game test harness descriptor. Consumed by `@vortex/game-extension-test`.
- *
- * The shape is mirrored from `IGameExtensionTestDescriptor` in
- * `@vortex/game-extension-test` to avoid the import dependency cycle during
- * normalization. Once the harness package is a devDep, the import can be
- * restored.
- */
-export const testDescriptor = {
- gameId: "xrebirth",
- nexusGameDomain: "xrebirth",
- fixtures: {
- mostPopular: 0,
- mostRecent: 0,
- oldest: 0,
- allCollections: false,
- all: true,
- } as const,
- syntheticContent: {
- "content.xml": ({ manifestId }: { manifestId: string }) =>
- ``,
- },
- skipHeuristics: [
- {
- reason: "Cheat Engine table (not an X Rebirth mod)",
- matches: (files) => files.length === 1 && /\.ct$/i.test(files[0]!),
- },
- {
- reason: "nested archive — user must extract before installing",
- matches: (files) => files.some((f) => /\.(7z|rar|zip)$/i.test(f)),
- },
- {
- reason: "single instruction text file (not installable content)",
- matches: (files) => files.length === 1 && /\.txt$/i.test(files[0]!),
- },
- {
- reason: "single XML config for an external tool",
- matches: (files) =>
- files.length === 1 && /\.xml$/i.test(files[0]!) && /(^|\/)(nesa|\d+-l\d+)/i.test(files[0]!),
- },
- ],
-};
diff --git a/game-description-language b/game-description-language
new file mode 160000
index 0000000000..b1aab385ef
--- /dev/null
+++ b/game-description-language
@@ -0,0 +1 @@
+Subproject commit b1aab385efbc4a81de1692db1b423c9d6b178346
diff --git a/packages/game-extension-test/package.json b/packages/game-extension-test/package.json
deleted file mode 100644
index 187a7033e7..0000000000
--- a/packages/game-extension-test/package.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "name": "@vortex/game-extension-test",
- "version": "0.1.0",
- "private": true,
- "type": "commonjs",
- "scripts": {
- "start": "pnpm tsx src/cli.ts",
- "build": "pnpm tsc",
- "typecheck": "pnpm tsc",
- "test": "pnpm vitest run"
- },
- "devDependencies": {
- "@nexusmods/nexus-api": "catalog:",
- "@nexusmods/vortex-api": "workspace:*",
- "@types/node": "catalog:",
- "@vortex/extension-test-mocks": "workspace:*",
- "limiter": "catalog:",
- "minimist": "catalog:",
- "typescript": "catalog:",
- "vitest": "catalog:"
- },
- "nx": {
- "tags": [
- "vortex:tooling"
- ],
- "targets": {
- "start": {
- "cache": false,
- "continuous": true,
- "dependsOn": [
- "build"
- ]
- }
- }
- }
-}
diff --git a/packages/game-extension-test/src/cli.ts b/packages/game-extension-test/src/cli.ts
deleted file mode 100644
index c2e7681918..0000000000
--- a/packages/game-extension-test/src/cli.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/usr/bin/env node
-import { spawnSync } from "node:child_process";
-import * as path from "node:path";
-
-import minimist from "minimist";
-
-// pnpm forwards a literal "--" separator into argv; minimist treats "--" as
-// stop-parsing, so strip it before parsing flags.
-const argv = minimist(process.argv.slice(2).filter((a) => a !== "--"));
-const all = argv.all === true || argv.all === "true";
-const single = typeof argv.game === "string" ? argv.game : undefined;
-const list = typeof argv.games === "string" ? argv.games.split(",") : undefined;
-
-if (!all && !single && !list) {
- console.error("Usage: --all | --game | --games ");
- process.exit(1);
-}
-
-const apiKey = process.env.NEXUS_API_KEY ?? "";
-if (!apiKey) {
- console.error("NEXUS_API_KEY environment variable is required.");
- process.exit(1);
-}
-
-const packageRoot = path.resolve(__dirname, "..");
-const repoRoot = path.resolve(__dirname, "../../..");
-const games = all ? "all" : (single ?? list?.join(","));
-
-const env = {
- ...process.env,
- GAME_EXT_TEST_REPO: repoRoot,
- GAME_EXT_TEST_GAMES: games,
-};
-
-const vitestConfig = path.join(packageRoot, "vitest.fixtures.config.ts");
-const result = spawnSync("pnpm", ["exec", "vitest", "run", "--config", vitestConfig], {
- stdio: "inherit",
- env,
- cwd: packageRoot,
-});
-process.exit(result.status ?? 1);
diff --git a/packages/game-extension-test/src/discovery.ts b/packages/game-extension-test/src/discovery.ts
deleted file mode 100644
index 401561c924..0000000000
--- a/packages/game-extension-test/src/discovery.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import * as fs from "node:fs";
-import * as path from "node:path";
-
-/**
- * An opted-in extension on disk.
- */
-export interface IDiscoveredExtension {
- packageJsonPath: string;
- packageDir: string;
- packageName: string;
-}
-
-/**
- * Walk `extensions/games/*` and return the ones whose package.json declares
- * `vortex.gameExtensionTest === true`.
- */
-export function discoverExtensions(repoRoot: string): IDiscoveredExtension[] {
- const gamesDir = path.join(repoRoot, "extensions", "games");
- if (!fs.existsSync(gamesDir)) return [];
- const out: IDiscoveredExtension[] = [];
- for (const entry of fs.readdirSync(gamesDir, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- const pkgPath = path.join(gamesDir, entry.name, "package.json");
- if (!fs.existsSync(pkgPath)) continue;
- let pkg: any;
- try {
- pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
- } catch {
- continue;
- }
- if (pkg?.vortex?.gameExtensionTest === true) {
- out.push({
- packageJsonPath: pkgPath,
- packageDir: path.dirname(pkgPath),
- packageName: pkg.name,
- });
- }
- }
- return out;
-}
diff --git a/packages/game-extension-test/src/index.ts b/packages/game-extension-test/src/index.ts
deleted file mode 100644
index eea524d655..0000000000
--- a/packages/game-extension-test/src/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./types";
diff --git a/packages/game-extension-test/src/loadExtension.ts b/packages/game-extension-test/src/loadExtension.ts
deleted file mode 100644
index 65be363165..0000000000
--- a/packages/game-extension-test/src/loadExtension.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import * as path from "node:path";
-
-import type { IGameExtensionTestDescriptor, IModCheckContext } from "./types";
-
-/**
- * Subset of Vortex's `TestSupported` signature relevant to the harness — we
- * only ever call it with `(files, gameId)`. The renderer's full type accepts
- * an optional `details` arg that no test driver passes.
- */
-export type HarnessTestSupported = (
- files: string[],
- gameId: string,
-) => Promise<{ supported: boolean; requiredFiles: string[] }>;
-
-/**
- * Subset of Vortex's `InstallFunc` signature relevant to the harness. The
- * production type takes 8 positional args (destination, gameId, progress, etc.)
- * — we forward them all but only consume `{ instructions }` from the result.
- */
-export type HarnessInstall = (
- files: string[],
- destinationPath: string,
- gameId: string,
- progressDelegate: (perc: number) => void,
- choices: unknown,
- unattended: boolean,
- archivePath: string | undefined,
- archiveOptions: Record,
-) => Promise<{ instructions: Array<{ type: string; [key: string]: unknown }> }>;
-
-export interface IInstallerEntry {
- id: string;
- priority: number;
- testSupported: HarnessTestSupported;
- install: HarnessInstall;
-}
-
-/**
- * Per-mod healthcheck shape consumed by `runFixture`. Structural — kept local
- * so the harness doesn't need a runtime dep on `vortex-api`. Mirrors
- * `IModHealthCheck` from `src/renderer/src/types/IHealthCheck.ts`.
- */
-export interface IHarnessModHealthCheck {
- id: string;
- checkMod: (
- api: unknown,
- modCtx: IModCheckContext,
- ) => Promise<{
- status: "passed" | "failed" | "warning" | "error";
- severity: string;
- message: string;
- details?: string;
- }>;
-}
-
-/** Minimal shape of the `IGame` object the extension passes to `registerGame`. */
-export interface IHarnessGame {
- id: string;
- name?: string;
- details?: { stopPatterns?: readonly string[]; [key: string]: unknown };
- [key: string]: unknown;
-}
-
-/**
- * Result of loading an extension. `installers` is sorted by ascending priority
- * to mirror Vortex's `InstallManager.getInstaller` dispatch order (lower
- * priority number wins).
- */
-export interface ILoadedExtension {
- installers: IInstallerEntry[];
- testDescriptor: IGameExtensionTestDescriptor;
- /** Each IModHealthCheck the extension exports from src/diagnostic.ts. */
- healthChecks: IHarnessModHealthCheck[];
- gameId: string;
- game: IHarnessGame;
-}
-
-/**
- * Subset of the renderer's IExtensionContext the harness honors. Everything
- * else is captured by a Proxy as a no-op so an extension calling, say,
- * `context.registerReducer(...)` during init() doesn't crash the loader.
- */
-interface IStubContext {
- _installers: IInstallerEntry[];
- _game: IHarnessGame | undefined;
- registerGame: (game: IHarnessGame) => void;
- registerInstaller: (
- id: string,
- priority: number,
- testSupported: HarnessTestSupported,
- install: HarnessInstall,
- ) => void;
- once: (cb: () => void) => void;
- api: Record;
- [hook: string]: unknown;
-}
-
-export async function loadExtension(extensionDir: string): Promise {
- const stubContext = makeStubContext();
- const indexPath = path.join(extensionDir, "src", "index.ts");
- const mod = await import(indexPath);
- const init = mod.default ?? mod.init;
- if (typeof init !== "function") {
- throw new Error(`Extension ${extensionDir} has no default export`);
- }
- init(stubContext);
-
- if (stubContext._installers.length === 0) {
- throw new Error(`Extension ${extensionDir} did not call registerInstaller`);
- }
- if (!stubContext._game) {
- throw new Error(`Extension ${extensionDir} did not call registerGame`);
- }
-
- const descriptorMod = (await import(path.join(extensionDir, "src", "test-descriptor.ts"))) as {
- testDescriptor?: IGameExtensionTestDescriptor;
- };
-
- // Tolerate a missing diagnostic.ts (extension hasn't added one yet), but
- // surface any other error so syntax mistakes don't silently become
- // "no health checks registered."
- const diagnosticPath = path.join(extensionDir, "src", "diagnostic.ts");
- let diagnosticMod: { healthChecks?: unknown } = {};
- try {
- diagnosticMod = (await import(diagnosticPath)) as { healthChecks?: unknown };
- } catch (err: unknown) {
- const code = (err as { code?: string } | null)?.code;
- if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") {
- throw err;
- }
- }
-
- if (!descriptorMod.testDescriptor) {
- throw new Error(
- `Extension ${extensionDir} does not export a testDescriptor ` +
- `(expected at src/test-descriptor.ts: export const testDescriptor = ...). ` +
- `The harness can't drive fixtures without one.`,
- );
- }
-
- const healthChecks = resolveHealthChecks(diagnosticMod);
- if (healthChecks.length === 0) {
- throw new Error(
- `Extension ${extensionDir} exports testDescriptor but has no health checks ` +
- `(expected at src/diagnostic.ts: export const healthChecks = [...] ` +
- `with at least one IModHealthCheck). ` +
- `Without a healthcheck the harness would silently pass every fixture.`,
- );
- }
-
- const installers: IInstallerEntry[] = [...stubContext._installers].sort(
- (a, b) => a.priority - b.priority,
- );
-
- return {
- installers,
- testDescriptor: descriptorMod.testDescriptor,
- healthChecks,
- gameId: stubContext._game.id,
- game: stubContext._game,
- };
-}
-
-function resolveHealthChecks(diagnosticMod: { healthChecks?: unknown }): IHarnessModHealthCheck[] {
- if (Array.isArray(diagnosticMod.healthChecks)) {
- return diagnosticMod.healthChecks as IHarnessModHealthCheck[];
- }
- return [];
-}
-
-function makeStubContext(): IStubContext {
- const base: IStubContext = {
- _installers: [],
- _game: undefined,
- registerGame(game) {
- base._game = game;
- },
- registerInstaller(id, priority, testSupported, install) {
- base._installers.push({ id, priority, testSupported, install });
- },
- once(_cb) {
- /* deferred init not exercised in tests */
- },
- api: {},
- };
- return new Proxy(base, {
- get(target, prop, receiver) {
- const known = Reflect.get(target, prop, receiver);
- if (known !== undefined) return known;
- if (typeof prop === "string" && prop.startsWith("register")) {
- return () => {
- /* unknown register hook — silently accepted */
- };
- }
- return undefined;
- },
- });
-}
diff --git a/packages/game-extension-test/src/manifest.ts b/packages/game-extension-test/src/manifest.ts
deleted file mode 100644
index eb07188896..0000000000
--- a/packages/game-extension-test/src/manifest.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { isRetryableStatus, withRetry } from "./retry";
-
-interface IPreviewNode {
- path?: string;
- name?: string;
- type?: "directory" | "file";
- size?: string;
- children?: IPreviewNode[];
-}
-
-/**
- * CDN fetches are unmetered, so retry faster than the SDK-routed calls in
- * `nexusClient.ts`.
- */
-export const CDN_RETRY = { maxAttempts: 4, baseDelayMs: 500, maxDelayMs: 10_000 } as const;
-
-function collectFiles(node: IPreviewNode, out: string[]): void {
- if (node.type === "file" && typeof node.path === "string") {
- out.push(node.path);
- return;
- }
- if (node.children) {
- for (const child of node.children) collectFiles(child, out);
- }
-}
-
-/**
- * Marker for HTTP responses that came back with a non-retryable status (404 in
- * practice). Callers can detect this to differentiate "manifest not on CDN"
- * from network failure.
- */
-export class FileManifestHttpError extends Error {
- constructor(
- message: string,
- readonly status: number,
- ) {
- super(message);
- this.name = "FileManifestHttpError";
- }
-}
-
-/**
- * Fetch the archive content-preview JSON from a public Nexus CDN URL and
- * flatten the tree into a list of archive-internal file paths. Retries on
- * transient HTTP / network failures; throws `FileManifestHttpError` on
- * non-retryable HTTP statuses (typically 404 = preview not on CDN).
- */
-export async function fetchFileManifest(
- contentPreviewLink: string,
- opts: { maxAttempts?: number } = {},
-): Promise {
- if (!contentPreviewLink) {
- throw new Error("fetchFileManifest: empty content_preview_link");
- }
- const url = encodeURI(contentPreviewLink);
-
- const resp = await withRetry(
- async () => {
- const r = await fetch(url);
- if (!r.ok && isRetryableStatus(r.status)) {
- const err = new Error(`HTTP ${r.status}`) as Error & { status: number };
- err.status = r.status;
- throw err;
- }
- return r;
- },
- { ...CDN_RETRY, maxAttempts: opts.maxAttempts ?? CDN_RETRY.maxAttempts },
- );
-
- if (!resp.ok) {
- throw new FileManifestHttpError(
- `fetchFileManifest: ${url} returned HTTP ${resp.status}`,
- resp.status,
- );
- }
-
- const tree = (await resp.json()) as IPreviewNode;
- const out: string[] = [];
- collectFiles(tree, out);
- return out;
-}
diff --git a/packages/game-extension-test/src/materializeInstall.ts b/packages/game-extension-test/src/materializeInstall.ts
deleted file mode 100644
index a4eb06730b..0000000000
--- a/packages/game-extension-test/src/materializeInstall.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { IModCheckContext } from "./types";
-import { lastSegment } from "./util";
-
-/**
- * Convert an installer's IInstruction[] output into the IModCheckContext that
- * a per-mod healthcheck consumes.
- *
- * Copy instructions become the file list (with `destination` as path under the
- * mod root). Attribute instructions become the attributes map.
- */
-export function materializeInstall(
- modId: string,
- instructions: Array<{
- type: string;
- destination?: string;
- key?: string;
- value?: unknown;
- }>,
- readFileForBasename: (basename: string) => Promise,
-): IModCheckContext {
- const files: string[] = [];
- const attributes: Record = {};
- for (const inst of instructions) {
- if (inst.type === "copy" && inst.destination) {
- files.push(inst.destination);
- } else if (inst.type === "attribute" && inst.key !== undefined) {
- attributes[inst.key] = inst.value;
- } else if (inst.type === "setmodtype") {
- attributes.modType = inst.value;
- }
- // generatefile, mkdir, etc. are ignored for now — extend as needed.
- }
- return {
- modId,
- files,
- readFile: (rel) => readFileForBasename(lastSegment(rel)),
- attributes,
- };
-}
diff --git a/packages/game-extension-test/src/mockApi.ts b/packages/game-extension-test/src/mockApi.ts
deleted file mode 100644
index 661d9c41d5..0000000000
--- a/packages/game-extension-test/src/mockApi.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { setReadFileResolver } from "@vortex/extension-test-mocks";
-import { vi } from "vitest";
-
-import type { IGameExtensionTestDescriptor, ISyntheticContext } from "./types";
-import { lastSegment } from "./util";
-
-export interface IMockApi {
- api: unknown; // IExtensionApi shape; consumed via duck-typing inside extensions
- readFileCalls: { path: string }[];
-}
-
-/**
- * Build the mocked IExtensionApi handed to the per-mod healthcheck. Also
- * primes the shared vortex-api mock's readFile resolver so that
- * `fs.readFileAsync` inside the installer reads from synthetic content.
- */
-export function buildMockApi(
- descriptor: IGameExtensionTestDescriptor,
- manifest: string[],
- ctx: ISyntheticContext,
-): IMockApi {
- const calls: { path: string }[] = [];
-
- setReadFileResolver(async (absPath: string) => {
- calls.push({ path: absPath });
- const baseName = lastSegment(absPath);
- const generator = descriptor.syntheticContent[baseName];
- if (!generator) return Buffer.alloc(0);
- const out = generator(ctx);
- return typeof out === "string" ? Buffer.from(out, "utf8") : out;
- });
-
- const api = {
- getState: () => ({
- persistent: { mods: {} },
- settings: { mods: { installPath: {} } },
- session: { base: { activeGameId: descriptor.gameId } },
- }),
- store: { getState: () => ({}), dispatch: vi.fn() },
- onStateChange: vi.fn(),
- showErrorNotification: vi.fn(),
- log: vi.fn(),
- ext: {},
- };
-
- return { api, readFileCalls: calls };
-}
diff --git a/packages/game-extension-test/src/nexusClient.ts b/packages/game-extension-test/src/nexusClient.ts
deleted file mode 100644
index 97547e8106..0000000000
--- a/packages/game-extension-test/src/nexusClient.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-import Nexus from "@nexusmods/nexus-api";
-import { RateLimiter } from "limiter";
-
-import { fetchFileManifest } from "./manifest";
-import { withRetry } from "./retry";
-
-export interface INexusClient {
- listMostPopular(gameDomain: string, limit: number): Promise;
- listMostRecent(gameDomain: string, limit: number): Promise;
- listOldest(gameDomain: string, limit: number): Promise;
- /**
- * Enumerate every mod for the game via paginated GraphQL. Caller is
- * responsible for the per-mod `listModFiles` follow-up.
- */
- listAllMods(gameDomain: string): Promise;
- listCollections(gameDomain: string): Promise;
- listCollectionMods(gameDomain: string, collectionSlug: string): Promise;
- listModFiles(gameDomain: string, modId: number): Promise;
- /**
- * Fetch the content-preview JSON for a file and flatten it into the list of
- * file paths inside the archive. Throws if the URL is empty or the fetch
- * fails.
- */
- getFileManifest(contentPreviewLink: string): Promise;
-}
-
-export interface INexusModSummary {
- modId: number;
- name: string;
-}
-
-export interface INexusCollectionSummary {
- slug: string;
- name: string;
-}
-
-export interface INexusFileSummary {
- fileId: number;
- /** Display name (human-readable, no extension). */
- name: string;
- /** Actual filename with extension. */
- fileName: string;
- /** Nexus category: "MAIN", "PATCH", "OPTIONAL", "OLD_VERSION", "MISCELLANEOUS", "DELETED", "ARCHIVED". */
- categoryName: string;
- uploadedAt: Date;
- /** URL of the archive content-preview JSON; empty string if not provided. */
- contentPreviewLink: string;
-}
-
-/** api.nexusmods.com is rate-limited; longer backoff than the unmetered CDN fetches in `manifest.ts`. */
-const SDK_RETRY = { maxAttempts: 4, baseDelayMs: 1000, maxDelayMs: 30_000 } as const;
-
-/** ~25 requests per second matches the published Nexus API quota with a small safety margin. */
-const RATE_LIMIT_PER_SEC = 25;
-
-/** GraphQL page size for `listAllMods`. */
-const GRAPHQL_PAGE_SIZE = 100;
-
-/** Default page size for collection-listing GraphQL calls. */
-const COLLECTION_LIST_PAGE_SIZE = 100;
-
-/**
- * The Nexus SDK's type surface in v1.6.0 is incomplete: `getModFiles` results
- * include `file_name` / `category_name` that aren't declared, and the
- * collection GraphQL methods aren't on the typed interface at all. We cast
- * once at the boundary so consumer code can stay strictly-typed.
- */
-interface IRawNexusFile {
- file_id: number;
- name: string;
- uploaded_timestamp: number;
- file_name?: string;
- category_name?: string;
- content_preview_link?: string;
-}
-
-interface IRawNexusCollectionListItem {
- slug?: string;
- name?: string;
-}
-
-interface IRawNexusCollectionDetail {
- currentRevision?: { modFiles?: Array<{ file?: { modId?: number; name?: string } }> };
-}
-
-interface INexusGraphQL {
- getCollectionListGraph(
- query: object,
- gameDomain: string,
- count: number,
- offset: number,
- ): Promise;
- getCollectionGraph(
- query: object,
- slug: string,
- adult: boolean,
- ): Promise;
-}
-
-interface IListAllModsResponse {
- data?: {
- mods?: { totalCount?: number; nodes?: Array<{ modId: number; name: string }> };
- };
- errors?: Array<{ message: string }>;
-}
-
-export function createNexusClient(apiKey: string): INexusClient {
- const limiter = new RateLimiter({ tokensPerInterval: RATE_LIMIT_PER_SEC, interval: "second" });
-
- let nexusPromise: Promise | undefined;
-
- function getNexus(): Promise {
- if (!nexusPromise) {
- nexusPromise = Nexus.create(apiKey, "vortex-game-extension-test", "1.0.0", "site");
- }
- return nexusPromise;
- }
-
- async function call(fn: (nexus: Nexus) => Promise): Promise {
- await limiter.removeTokens(1);
- const nexus = await getNexus();
- return withRetry(() => fn(nexus), SDK_RETRY);
- }
-
- return {
- // getTrending sorts by endorsements/popularity.
- async listMostPopular(gameDomain: string, limit: number): Promise {
- const results = await call((nexus) => nexus.getTrending(gameDomain));
- return results.slice(0, limit).map((m) => ({ modId: m.mod_id, name: m.name ?? "" }));
- },
-
- async listMostRecent(gameDomain: string, limit: number): Promise {
- const results = await call((nexus) => nexus.getLatestAdded(gameDomain));
- return results.slice(0, limit).map((m) => ({ modId: m.mod_id, name: m.name ?? "" }));
- },
-
- // The REST API has no "oldest" sort; getLatestUpdated returns ascending by
- // update time, so reversing puts the least-recently-updated mods first.
- // This is the closest approximation available in @nexusmods/nexus-api v1.6.0.
- async listOldest(gameDomain: string, limit: number): Promise {
- const results = await call((nexus) => nexus.getLatestUpdated(gameDomain));
- return results
- .slice()
- .reverse()
- .slice(0, limit)
- .map((m) => ({ modId: m.mod_id, name: m.name ?? "" }));
- },
-
- // The SDK doesn't expose raw GraphQL on the typed surface, so hit
- // /v2/graphql directly. Wrapped in withRetry to match call()'s protection.
- async listAllMods(gameDomain: string): Promise {
- const out: INexusModSummary[] = [];
- let offset = 0;
- while (true) {
- const data = await withRetry(async () => {
- await limiter.removeTokens(1);
- const resp = await fetch("https://api.nexusmods.com/v2/graphql", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- APIKEY: apiKey,
- },
- body: JSON.stringify({
- query:
- "query($domain: String!, $count: Int!, $offset: Int!) {" +
- " mods(filter: { filter: [{ gameDomainName: { value: $domain, op: EQUALS } }] }, count: $count, offset: $offset) {" +
- " totalCount nodes { modId name }" +
- " } }",
- variables: { domain: gameDomain, count: GRAPHQL_PAGE_SIZE, offset },
- }),
- });
- if (!resp.ok) {
- // Tag with `status` so withRetry only retries 408/429/5xx.
- const err = Object.assign(new Error(`listAllMods: HTTP ${resp.status}`), {
- status: resp.status,
- });
- throw err;
- }
- const json = (await resp.json()) as IListAllModsResponse;
- if (json.errors?.length) {
- // GraphQL semantic errors arrive over 200; tag non-retryable so
- // withRetry fails fast.
- const err = Object.assign(
- new Error(`listAllMods GraphQL: ${json.errors.map((e) => e.message).join("; ")}`),
- { status: 400 },
- );
- throw err;
- }
- return json;
- }, SDK_RETRY);
-
- const page = data.data?.mods?.nodes ?? [];
- for (const m of page) out.push({ modId: m.modId, name: m.name ?? "" });
- const total = data.data?.mods?.totalCount ?? 0;
- offset += page.length;
- if (offset >= total || page.length === 0) break;
- }
- return out;
- },
-
- async listCollections(gameDomain: string): Promise {
- const query = { slug: true, name: true };
- const results = await call((nexus) =>
- (nexus as unknown as INexusGraphQL).getCollectionListGraph(
- query,
- gameDomain,
- COLLECTION_LIST_PAGE_SIZE,
- 0,
- ),
- );
- return results.map((c) => ({
- slug: c.slug ?? "",
- name: c.name ?? "",
- }));
- },
-
- async listCollectionMods(
- gameDomain: string,
- collectionSlug: string,
- ): Promise {
- const query = {
- slug: true,
- name: true,
- currentRevision: {
- modFiles: {
- file: {
- modId: true,
- name: true,
- },
- },
- },
- };
- const collection = await call((nexus) =>
- (nexus as unknown as INexusGraphQL).getCollectionGraph(query, collectionSlug, false),
- );
-
- const modFiles = collection.currentRevision?.modFiles ?? [];
- return modFiles
- .filter((mf) => mf.file?.modId != null)
- .map((mf) => ({
- modId: mf.file!.modId as number,
- name: mf.file!.name ?? "",
- }));
- },
-
- async listModFiles(gameDomain: string, modId: number): Promise {
- const result = await call((nexus) => nexus.getModFiles(modId, gameDomain));
- return (result.files as unknown as IRawNexusFile[]).map((f) => ({
- fileId: f.file_id,
- name: f.name,
- fileName: f.file_name ?? "",
- categoryName: f.category_name ?? "",
- uploadedAt: new Date(f.uploaded_timestamp * 1000),
- contentPreviewLink: f.content_preview_link ?? "",
- }));
- },
-
- getFileManifest: (contentPreviewLink: string) => fetchFileManifest(contentPreviewLink),
- };
-}
diff --git a/packages/game-extension-test/src/resolveFixtures.ts b/packages/game-extension-test/src/resolveFixtures.ts
deleted file mode 100644
index 5b571a6e5b..0000000000
--- a/packages/game-extension-test/src/resolveFixtures.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import type { INexusClient } from "./nexusClient";
-import type { IFixture, IGameExtensionTestDescriptor } from "./types";
-import { getErrorStatus } from "./util";
-
-/** Mod-level row before per-file expansion. */
-interface IModRef {
- origin: IFixture["origin"];
- modId: number;
-}
-
-/** File-level categories that we skip (Nexus's `category_name` values). */
-const EXCLUDED_CATEGORIES = new Set(["DELETED", "ARCHIVED"]);
-
-/** Filename extensions for which Nexus generates a content-preview JSON. */
-const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz"];
-
-/**
- * For each opted-in source in the descriptor, enumerate every non-deleted,
- * non-archived archive file for every selected mod. Returns one `IFixture` per
- * file (so a mod with five archive uploads becomes five fixtures).
- *
- * Parallelises the per-mod `listModFiles` calls via the client's built-in
- * rate-limiter (~25 req/s).
- */
-export async function resolveFixtures(
- client: INexusClient,
- descriptor: IGameExtensionTestDescriptor,
-): Promise {
- const seenMod = new Set();
- const modRefs: IModRef[] = [];
- const tryAddMod = (r: IModRef) => {
- if (seenMod.has(r.modId)) return;
- seenMod.add(r.modId);
- modRefs.push(r);
- };
-
- const d = descriptor.nexusGameDomain;
- if (descriptor.fixtures.all) {
- for (const m of await client.listAllMods(d)) {
- tryAddMod({ origin: "all", modId: m.modId });
- }
- }
- if (descriptor.fixtures.mostPopular > 0) {
- for (const m of await client.listMostPopular(d, descriptor.fixtures.mostPopular)) {
- tryAddMod({ origin: "mostPopular", modId: m.modId });
- }
- }
- if (descriptor.fixtures.mostRecent > 0) {
- for (const m of await client.listMostRecent(d, descriptor.fixtures.mostRecent)) {
- tryAddMod({ origin: "mostRecent", modId: m.modId });
- }
- }
- if (descriptor.fixtures.oldest > 0) {
- for (const m of await client.listOldest(d, descriptor.fixtures.oldest)) {
- tryAddMod({ origin: "oldest", modId: m.modId });
- }
- }
- if (descriptor.fixtures.allCollections) {
- let cols: Awaited>;
- try {
- cols = await client.listCollections(d);
- } catch (err: unknown) {
- console.warn(
- `resolveFixtures: listCollections failed for ${d}; skipping collection fixtures. ` +
- (err instanceof Error ? err.message : String(err)),
- );
- cols = [];
- }
- for (const c of cols) {
- try {
- for (const m of await client.listCollectionMods(d, c.slug)) {
- tryAddMod({ origin: { type: "collection", collectionId: c.slug }, modId: m.modId });
- }
- } catch (err: unknown) {
- console.warn(
- `resolveFixtures: collection ${c.slug} failed; skipping. ` +
- (err instanceof Error ? err.message : String(err)),
- );
- }
- }
- }
-
- // Fan out: one listModFiles per mod, throttled by the client's rate-limiter.
- // Individual 403/404s (deleted/hidden mods) are swallowed; everything else
- // propagates and aborts the run.
- const perModFiles = await Promise.all(
- modRefs.map(async (ref) => {
- try {
- const files = await client.listModFiles(d, ref.modId);
- return { ref, files };
- } catch (err: unknown) {
- const status = getErrorStatus(err);
- if (status === 403 || status === 404) {
- return { ref, files: [] };
- }
- throw err;
- }
- }),
- );
-
- const out: IFixture[] = [];
- const seenFile = new Set();
- for (const { ref, files } of perModFiles) {
- for (const f of files) {
- if (EXCLUDED_CATEGORIES.has(f.categoryName)) continue;
- if (!isArchiveFile(f.fileName)) continue;
- if (seenFile.has(f.fileId)) continue;
- seenFile.add(f.fileId);
- out.push({
- origin: ref.origin,
- modId: ref.modId,
- fileId: f.fileId,
- fileName: f.fileName,
- contentPreviewLink: f.contentPreviewLink,
- });
- }
- }
- return out;
-}
-
-function isArchiveFile(name: string): boolean {
- const lower = name.toLowerCase();
- return ARCHIVE_EXTENSIONS.some((ext) => lower.endsWith(ext));
-}
diff --git a/packages/game-extension-test/src/retry.ts b/packages/game-extension-test/src/retry.ts
deleted file mode 100644
index 44cc1172a3..0000000000
--- a/packages/game-extension-test/src/retry.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { getErrorStatus } from "./util";
-
-/**
- * Shared retry primitive used by both the Nexus SDK wrapper and direct CDN
- * fetches. Retries network-level failures and 408/429/5xx; bails on everything
- * else. Callers wrapping `fetch` must throw a marker error themselves on
- * retryable HTTP statuses since `fetch` doesn't throw on non-2xx.
- */
-export interface IRetryOptions {
- maxAttempts?: number;
- baseDelayMs?: number;
- maxDelayMs?: number;
-}
-
-export function isRetryableStatus(status: number): boolean {
- return status === 408 || status === 429 || (status >= 500 && status < 600);
-}
-
-export const sleep = (ms: number): Promise =>
- new Promise((resolve) => setTimeout(resolve, ms));
-
-export async function withRetry(fn: () => Promise, opts: IRetryOptions = {}): Promise {
- const { maxAttempts = 4, baseDelayMs = 500, maxDelayMs = 10_000 } = opts;
- let lastErr: unknown;
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
- try {
- return await fn();
- } catch (err: unknown) {
- lastErr = err;
- const status = getErrorStatus(err);
- const transient = status === undefined || isRetryableStatus(status);
- if (!transient || attempt === maxAttempts - 1) {
- throw err;
- }
-
- const base = baseDelayMs * 2 ** attempt;
- const jitter = Math.random() * base * 0.5;
- await sleep(Math.min(base + jitter, maxDelayMs));
- }
- }
- throw lastErr;
-}
diff --git a/packages/game-extension-test/src/runFixture.ts b/packages/game-extension-test/src/runFixture.ts
deleted file mode 100644
index ab5e1a5d15..0000000000
--- a/packages/game-extension-test/src/runFixture.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import type { ILoadedExtension } from "./loadExtension";
-import { materializeInstall } from "./materializeInstall";
-import { buildMockApi } from "./mockApi";
-import type { FixtureOutcome, IFixture, IGameExtensionTestDescriptor } from "./types";
-
-const VIRTUAL_DEST = "/virtual-dest";
-
-export async function runFixture(
- ext: ILoadedExtension,
- fixture: IFixture,
- manifest: string[],
-): Promise {
- const descriptor: IGameExtensionTestDescriptor = ext.testDescriptor;
- const ctx = {
- manifestId: `${fixture.modId}-${fixture.fileId}`,
- modId: fixture.modId,
- fileId: fixture.fileId,
- };
- const { api } = buildMockApi(descriptor, manifest, ctx);
-
- // Walk installers in ascending priority order (mirrors Vortex's
- // InstallManager.getInstaller dispatch). First one returning supported=true wins.
- let chosen: (typeof ext.installers)[number] | undefined;
- for (const inst of ext.installers) {
- let supported: { supported: boolean; requiredFiles: string[] };
- try {
- supported = await inst.testSupported(manifest, ext.gameId);
- } catch (err: unknown) {
- return {
- kind: "failed",
- issues: [`testSupported (${inst.id}) threw: ${errorMessage(err)}`],
- };
- }
- if (supported.supported) {
- chosen = inst;
- break;
- }
- }
- if (!chosen) {
- return { kind: "rejected", reason: "no installer accepted the file" };
- }
-
- let result: { instructions: Array<{ type: string; [key: string]: unknown }> };
- try {
- result = await chosen.install(
- manifest,
- VIRTUAL_DEST,
- ext.gameId,
- () => {
- /* noop progress */
- },
- undefined,
- true,
- undefined,
- {},
- );
- } catch (err: unknown) {
- return { kind: "failed", issues: [`install (${chosen.id}) threw: ${errorMessage(err)}`] };
- }
-
- const modCtx = materializeInstall(ctx.manifestId, result.instructions, async (basename) => {
- const generator = descriptor.syntheticContent[basename];
- if (!generator) return Buffer.alloc(0);
- const out = generator(ctx);
- return typeof out === "string" ? Buffer.from(out, "utf8") : out;
- });
-
- const issues: string[] = [];
- const messages: string[] = [];
- for (const hc of ext.healthChecks) {
- const checkResult = await hc.checkMod(api, modCtx);
- if (checkResult.status === "failed" || checkResult.status === "error") {
- issues.push(`${hc.id} (${checkResult.severity}): ${checkResult.message}`);
- } else {
- messages.push(`${hc.id}: ${checkResult.message}`);
- }
- }
- if (issues.length > 0) {
- return { kind: "failed", issues };
- }
- return { kind: "passed", modCheckMessage: messages.join("; ") };
-}
-
-function errorMessage(err: unknown): string {
- if (err instanceof Error) return err.message;
- return String(err);
-}
diff --git a/packages/game-extension-test/src/runOneFixture.ts b/packages/game-extension-test/src/runOneFixture.ts
deleted file mode 100644
index 8be3016ac2..0000000000
--- a/packages/game-extension-test/src/runOneFixture.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { loadExtension } from "./loadExtension";
-import { FileManifestHttpError, fetchFileManifest } from "./manifest";
-import { runFixture } from "./runFixture";
-import type { IFixture } from "./types";
-
-/**
- * Run a single pre-resolved fixture: fetch the manifest, drive the installer,
- * run the diagnostic.
- *
- * Returns a skip reason string when something genuinely couldn't be tested
- * (manifest missing on the CDN). Throws on real failures, including the
- * installer rejecting the file — rejection means "no installer handler
- * registered for this file type" and is a signal we need to address, not hide.
- */
-export async function runOneFixture(args: {
- extensionDir: string;
- fixture: IFixture;
-}): Promise {
- if (!args.fixture.contentPreviewLink) {
- return `no content_preview_link for fileId=${args.fixture.fileId}`;
- }
-
- let manifest: string[];
- try {
- manifest = await fetchFileManifest(args.fixture.contentPreviewLink);
- } catch (err: unknown) {
- if (err instanceof FileManifestHttpError && err.status === 404) {
- return `manifest not on CDN: ${err.message}`;
- }
- throw err;
- }
-
- const ext = await loadExtension(args.extensionDir);
-
- const skipHeuristics = ext.testDescriptor.skipHeuristics ?? [];
- for (const h of skipHeuristics) {
- if (h.matches(manifest)) {
- return `skipped by heuristic: ${h.reason}`;
- }
- }
-
- const outcome = await runFixture(ext, args.fixture, manifest);
- if (outcome.kind === "failed") {
- throw new Error(outcome.issues.join("; "));
- }
- if (outcome.kind === "rejected") {
- throw new Error(
- `[${ext.gameId}] installer rejected file ${args.fixture.fileName} (modId=${args.fixture.modId}, fileId=${args.fixture.fileId}). ` +
- `If this is intentional, add a more specific installer that supports this file shape; ` +
- `otherwise an existing installer's testSupported needs to accept it.`,
- );
- }
- return undefined;
-}
diff --git a/packages/game-extension-test/src/test-entry.test.ts b/packages/game-extension-test/src/test-entry.test.ts
deleted file mode 100644
index fac0bd7f84..0000000000
--- a/packages/game-extension-test/src/test-entry.test.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import * as path from "node:path";
-
-import { test } from "vitest";
-
-import { discoverExtensions } from "./discovery";
-import { createNexusClient } from "./nexusClient";
-import { resolveFixtures } from "./resolveFixtures";
-import { runOneFixture } from "./runOneFixture";
-
-/**
- * Single test file that fans out to one `test.concurrent` per Nexus *file*
- * (every non-deleted, non-archived archive across every selected mod).
- *
- * The CLI sets GAME_EXT_TEST_REPO and GAME_EXT_TEST_GAMES; without those, the
- * file degrades to a single no-op test.
- */
-
-const repoRoot = process.env.GAME_EXT_TEST_REPO;
-const games = process.env.GAME_EXT_TEST_GAMES ?? "all";
-const apiKey = process.env.NEXUS_API_KEY ?? "";
-
-if (!repoRoot || !apiKey) {
- test("environment not configured (skipped)", () => {
- // The CLI sets these vars; ad-hoc `vitest run` outside the CLI skips here.
- });
-} else {
- const requested = games === "all" ? null : games.split(",");
- const exts = discoverExtensions(repoRoot);
- const selected =
- requested === null
- ? exts
- : exts.filter(
- (e) =>
- requested.includes(e.packageName) ||
- requested.some((g) => e.packageDir.endsWith(`/${g}`)),
- );
-
- const client = createNexusClient(apiKey);
- for (const found of selected) {
- // @ts-ignore TS1378 — top-level await works under vitest's Vite transform.
- const descriptor = // @ts-ignore TS1378
- (await import(path.join(found.packageDir, "src", "test-descriptor.ts"))).testDescriptor;
- // @ts-ignore TS1378
- const fixtures = await resolveFixtures(client, descriptor);
- for (const fx of fixtures) {
- test.concurrent(`${descriptor.gameId} > modId=${fx.modId} fileId=${fx.fileId} (${fx.fileName})`, async (ctx) => {
- const skipReason = await runOneFixture({
- extensionDir: found.packageDir,
- fixture: fx,
- });
- if (skipReason) ctx.skip(skipReason);
- });
- }
- }
-}
diff --git a/packages/game-extension-test/src/types.ts b/packages/game-extension-test/src/types.ts
deleted file mode 100644
index 9a995392c3..0000000000
--- a/packages/game-extension-test/src/types.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * Per-game opt-in descriptor exported as `testDescriptor` from each extension.
- */
-export interface IGameExtensionTestDescriptor {
- /** Internal Vortex game id, matches the value passed to registerGame. */
- gameId: string;
-
- /** Nexus game-domain slug used when calling the Nexus API. */
- nexusGameDomain: string;
-
- fixtures: {
- mostPopular: number;
- mostRecent: number;
- oldest: number;
- allCollections: boolean;
- /**
- * When true, fetch *every* mod for the game via paginated GraphQL.
- * For small games this is fine; for large games (Skyrim, etc.) it
- * generates a lot of API traffic.
- */
- all: boolean;
- };
-
- /**
- * Maps a filename (matched by `path.basename`) to a generator that returns
- * the bytes/string to return when the installer reads that file.
- *
- * The generator receives a context object so different fixtures can produce
- * different content if needed.
- */
- syntheticContent: Record string | Buffer>;
-
- /**
- * Heuristics that classify fixtures as "not worth running through the
- * installer chain" (cheat tables, nested archives requiring prior
- * extraction, single instruction-text uploads, etc.).
- *
- * Evaluated after the manifest is fetched, before testSupported runs. If
- * any heuristic's `matches(files)` returns true, the fixture is reported as
- * skipped with the heuristic's reason and the installer chain is bypassed.
- */
- skipHeuristics?: ISkipHeuristic[];
-}
-
-export interface ISkipHeuristic {
- reason: string;
- matches: (files: string[]) => boolean;
-}
-
-export interface ISyntheticContext {
- /** Stable identifier derived from manifest's file_id (for use as mod id). */
- manifestId: string;
- modId: number;
- fileId: number;
-}
-
-/** A single fixture row resolved from the Nexus API. */
-export interface IFixture {
- origin:
- | "all"
- | "mostPopular"
- | "mostRecent"
- | "oldest"
- | { type: "collection"; collectionId: string };
- modId: number;
- fileId: number;
- fileName: string;
- /** URL of the file's content-preview JSON (manifest endpoint). */
- contentPreviewLink: string;
- /** File-tree manifest (paths relative to archive root). Lazy-fetched. */
- manifest?: string[];
-}
-
-/** Outcome of running one fixture. */
-export type FixtureOutcome =
- | { kind: "passed"; modCheckMessage: string }
- | { kind: "rejected"; reason: string }
- | { kind: "failed"; issues: string[] }
- | { kind: "skipped"; reason: string };
-
-/**
- * Per-mod context passed to a healthcheck. Mirrors the framework's
- * IModCheckContext shape; kept here to avoid a cross-package import.
- */
-export interface IModCheckContext {
- modId: string;
- files: string[];
- readFile: (path: string) => Promise;
- attributes: Record;
-}
diff --git a/packages/game-extension-test/src/util.ts b/packages/game-extension-test/src/util.ts
deleted file mode 100644
index c5a1f70e15..0000000000
--- a/packages/game-extension-test/src/util.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export function lastSegment(p: string): string {
- const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
- return idx === -1 ? p : p.slice(idx + 1);
-}
-
-export function getErrorStatus(err: unknown): number | undefined {
- if (typeof err !== "object" || err === null) return undefined;
- const e = err as Record;
- if (typeof e.statusCode === "number") return e.statusCode;
- if (typeof e.status === "number") return e.status;
- return undefined;
-}
diff --git a/packages/game-extension-test/tsconfig.json b/packages/game-extension-test/tsconfig.json
deleted file mode 100644
index 39ecff5e9d..0000000000
--- a/packages/game-extension-test/tsconfig.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "$schema": "https://www.schemastore.org/tsconfig.json",
- "extends": "../../tsconfig.strict.json",
- "include": ["./src/**/*.ts", "../../typings.custom/promise.d.ts"],
- "compilerOptions": {
- "composite": true,
- "noEmit": true,
- "target": "esnext",
- "module": "esnext",
- "moduleResolution": "bundler",
- "customConditions": ["development"],
- "lib": ["ESNext"],
- "types": ["node"],
- "allowJs": false,
- "isolatedDeclarations": true,
- "noUncheckedSideEffectImports": true
- }
-}
diff --git a/packages/game-extension-test/vitest.config.ts b/packages/game-extension-test/vitest.config.ts
deleted file mode 100644
index 9459008aa4..0000000000
--- a/packages/game-extension-test/vitest.config.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { createRequire } from "node:module";
-
-import { mergeConfig, defineConfig } from "vitest/config";
-
-import baseConfig from "../../vitest.base.config";
-
-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/packages/game-extension-test/vitest.fixtures.config.ts b/packages/game-extension-test/vitest.fixtures.config.ts
deleted file mode 100644
index 58fdbb9f90..0000000000
--- a/packages/game-extension-test/vitest.fixtures.config.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { createRequire } from "node:module";
-
-import { defineConfig } from "vitest/config";
-
-/**
- * Vitest config for the dynamic fixture runner driven by `cli.ts`. Each
- * `test.concurrent` invocation handles one Nexus file.
- */
-const require_ = createRequire(import.meta.url);
-const VORTEX_API_MOCK = require_.resolve("@vortex/extension-test-mocks");
-
-// Each fixture makes at least two HTTP calls (listModFiles + manifest); a slow
-// Nexus response can easily exceed vitest's 5s default.
-const FIXTURE_TEST_TIMEOUT_MS = 30_000;
-
-// One authenticated Nexus call per fixture; staying just under the SDK's
-// 25-req/s burst keeps headroom for the unmetered CDN manifest fetches.
-const FIXTURE_MAX_CONCURRENCY = 24;
-
-export default defineConfig({
- resolve: {
- alias: [{ find: /^@nexusmods\/vortex-api$/, replacement: VORTEX_API_MOCK }],
- },
- test: {
- environment: "node",
- include: ["src/test-entry.test.ts"],
- testTimeout: FIXTURE_TEST_TIMEOUT_MS,
- maxConcurrency: FIXTURE_MAX_CONCURRENCY,
- },
-});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 966f67423d..e1bd2dbbe9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2656,9 +2656,6 @@ importers:
'@vortex/extension-test-mocks':
specifier: workspace:*
version: link:../../../packages/extension-test-mocks
- '@vortex/game-extension-test':
- specifier: workspace:*
- version: link:../../../packages/game-extension-test
extensions/gamestore-gog:
devDependencies:
@@ -4065,33 +4062,6 @@ importers:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(happy-dom@20.9.0)(vite@8.0.16)
- packages/game-extension-test:
- devDependencies:
- '@nexusmods/nexus-api':
- specifier: 'catalog:'
- version: https://codeload.github.com/Nexus-Mods/node-nexus-api/tar.gz/99a97cd1359527dbf5c46c93723d1846538badfe
- '@nexusmods/vortex-api':
- specifier: workspace:*
- version: link:../vortex-api
- '@types/node':
- specifier: 'catalog:'
- version: 24.12.2
- '@vortex/extension-test-mocks':
- specifier: workspace:*
- version: link:../extension-test-mocks
- limiter:
- specifier: 'catalog:'
- version: 3.0.0
- minimist:
- specifier: 'catalog:'
- version: 1.2.8
- typescript:
- specifier: 'catalog:'
- version: 6.0.3
- vitest:
- specifier: 'catalog:'
- version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(happy-dom@20.9.0)(vite@8.0.16)
-
packages/icon-extract:
devDependencies:
pe-resources:
diff --git a/scripts/build-gdl-extension.mjs b/scripts/build-gdl-extension.mjs
new file mode 100644
index 0000000000..5be3fb5b70
--- /dev/null
+++ b/scripts/build-gdl-extension.mjs
@@ -0,0 +1,45 @@
+import { execSync } from "node:child_process";
+import { existsSync, rmSync } from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(here, "..");
+const gdlPath = path.join(repoRoot, "game-description-language");
+
+/**
+ * Build a GDL (game.yaml) extension. The game-description-language submodule is
+ * a self-contained toolchain with its own dependencies and lockfile, so Vortex's
+ * own `pnpm install` does not reach into it. This ensures the submodule is
+ * installed and built (using its own pinned deps), then compiles the extension's
+ * game.yaml to dist/index.js. The install and build steps are skipped once their
+ * outputs exist, so repeat builds only pay for them once.
+ */
+export async function buildGdlExtension(extensionPath) {
+ if (!existsSync(gdlPath)) {
+ throw new Error(
+ `GDL submodule not found at ${gdlPath}. ` +
+ `Run \`git submodule update --init game-description-language\` first.`,
+ );
+ }
+
+ // Vortex's install does not reach into the submodule (it has its own
+ // workspace), so install its dependencies on first build.
+ if (!existsSync(path.join(gdlPath, "node_modules"))) {
+ execSync("pnpm install --frozen-lockfile", { cwd: gdlPath, stdio: "inherit" });
+ }
+
+ // Build the GDL CLI/toolchain on first build.
+ if (!existsSync(path.join(gdlPath, "dist", "cli.js"))) {
+ execSync("pnpm run build", { cwd: gdlPath, stdio: "inherit" });
+ }
+
+ // Clean previous output so stale artifacts never shadow a fresh build.
+ rmSync(path.join(extensionPath, "dist"), { recursive: true, force: true });
+
+ // Dynamic import needs a file:// URL for absolute paths on Windows.
+ const { buildExtension } = await import(
+ pathToFileURL(path.join(gdlPath, "dist", "commands", "build.js")).href
+ );
+ await buildExtension({ cwd: extensionPath });
+}