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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dist_portable
dist_web
.gdl-out
**/tests/cache
docs/superpowers/
out
doc
coverage
Expand Down
Binary file not shown.
Binary file not shown.
7 changes: 7 additions & 0 deletions extensions/games/game-pathfinderkingmaker/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as path from "node:path";

import { buildGdlExtension } from "../../../scripts/build-gdl-extension.mjs";

// This extension is described declaratively in game.yaml and compiled by the
// Game Description Language (GDL) toolchain into a Vortex-loadable dist/index.js.
await buildGdlExtension(path.resolve(import.meta.dirname));
78 changes: 78 additions & 0 deletions extensions/games/game-pathfinderkingmaker/game.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
gdl: 1
version: 0.2.0

game:
id: pathfinderkingmaker
name: "Pathfinder: Kingmaker"
executable: Kingmaker.exe
requiredFiles: [Kingmaker.exe]
logo: gameart.webp
author: m0nster
nexusDomain: pathfinderkingmaker
# Unity Mod Manager mods deploy under the game's Mods folder.
queryModPath: Mods

stores:
steam: "640820"

context:
# Custom character portraits live in the user's LocalLow profile, not the game
# install. Each portrait is a folder of Small/Medium/Fulllength PNGs.
portraitsRoot: ${appDataLocalLow}/Owlcat Games/Pathfinder Kingmaker/Portraits
# Wwise voice soundbanks overwrite the game's stock banks in its audio folder.
audioRoot: ${installPath}/Kingmaker_Data/StreamingAssets/Audio/Windows

modTypes:
- { id: portrait, name: Portrait, path: "${portraitsRoot}" }
- { id: voice, name: Voice soundbank, path: "${audioRoot}" }

# A portrait folder is identified by the Small + Medium + Fulllength PNG trio.
# UMM code mods (.dll) and in-game asset-bundle replacements are excluded so they
# keep deploying to Mods/ and the game data folder respectively.
installers:
# Portraits: any archive carrying the Small/Medium/Fulllength PNG trio. A hook
# deploys each portrait folder as Portraits/<name>/, covering single-folder,
# multi-portrait Portraits/ wrappers, and loose-at-root archives.
- id: portrait
priority: 25
when:
all:
- { hasFile: "**/Fulllength.png" }
- { hasFile: "**/Medium.png" }
- { hasFile: "**/Small.png" }
unless:
any:
- { hasFile: "**/*.dll" }
- { hasFile: "**/*_Data/StreamingAssets/**" }
install: { hook: installPortrait }

# Voice soundbanks (.bnk): a hook flattens each bank into the audio folder.
# Code mods (.dll) and data/loader mods (.json/.jbp/.patch) are excluded so
# they keep deploying to Mods/, as are multi-variant packs (handled in the hook).
- id: voice
priority: 30
when: { hasFile: "**/*.bnk" }
unless:
any:
- { hasFile: "**/*.dll" }
- { hasFile: "**/*.json" }
- { hasFile: "**/*.jbp" }
- { hasFile: "**/*.patch" }
install: { hook: installVoice }

setup:
ensureDirs:
- ${installPath}/Mods
requireFiles:
files:
- ${installPath}/UnityModManager/UnityModManager.dll
prompt:
title: Action required
message: >-
You must install Unity Mod Manager to use mods with Pathfinder: Kingmaker.
link:
label: Go to the Unity Mod Manager page
mod: { domain: site, modId: 21 }

tests:
corpus: nexus
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions extensions/games/game-pathfinderkingmaker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"version": "0.2.0",
"description": "Support for Pathfinder: Kingmaker",
"scripts": {
"_assets": "copyfiles -u 1 -f ./assets/* dist",
"build": "copyfiles -u 1 -f ./src/index.js dist && pnpm run _assets && pnpm extractInfo"
"build": "node build.mjs",
"test": "pnpm exec vitest run --passWithNoTests"
},
"author": "m0nster",
"license": "GPL-3.0",
Expand All @@ -14,7 +14,8 @@
"game": "Pathfinder: Kingmaker"
},
"devDependencies": {
"@nexusmods/vortex-api": "workspace:*"
"@nexusmods/vortex-api": "workspace:*",
"@vortex/extension-test-mocks": "workspace:*"
},
"nx": {
"tags": [
Expand Down
107 changes: 107 additions & 0 deletions extensions/games/game-pathfinderkingmaker/src/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";

import { installPortrait, installVoice } from "./hooks";

type Inst = { type: string; source?: string; destination?: string; value?: unknown };
const copies = (result: { instructions: Inst[] }): Inst[] =>
result.instructions.filter((i) => i.type === "copy");
const modType = (result: { instructions: Inst[] }): unknown =>
result.instructions.find((i) => i.type === "setmodtype")?.value;

describe("installPortrait", () => {
it("deploys a single named portrait folder under the portrait mod type", async () => {
const result = await installPortrait(
["9999/Fulllength.png", "9999/Medium.png", "9999/Small.png", "9999/Thumbs.db"],
"/staging/x.installing",
);
expect(modType(result)).toBe("portrait");
expect(copies(result)).toEqual([
{ type: "copy", source: "9999/Fulllength.png", destination: "9999/Fulllength.png" },
{ type: "copy", source: "9999/Medium.png", destination: "9999/Medium.png" },
{ type: "copy", source: "9999/Small.png", destination: "9999/Small.png" },
]);
});

it("keeps every portrait in a multi-portrait Portraits/ wrapper, stripping the wrapper", async () => {
const result = await installPortrait(
[
"Portraits/001Mercy/Fulllength.png",
"Portraits/001Mercy/Medium.png",
"Portraits/001Mercy/Small.png",
"Portraits/002Mercy/Fulllength.png",
"Portraits/002Mercy/Medium.png",
"Portraits/002Mercy/Small.png",
],
"/staging/x",
);
const dests = copies(result)
.map((c) => c.destination)
.sort();
expect(dests).toEqual([
"001Mercy/Fulllength.png",
"001Mercy/Medium.png",
"001Mercy/Small.png",
"002Mercy/Fulllength.png",
"002Mercy/Medium.png",
"002Mercy/Small.png",
]);
});

it("synthesises a folder name for a loose-at-root portrait and drops non-images", async () => {
const result = await installPortrait(
["Fulllength.png", "Medium.png", "Small.png", "Readme.txt"],
"/staging/Concept_Art_Linzi.installing",
);
expect(copies(result)).toEqual([
{ type: "copy", source: "Fulllength.png", destination: "Concept_Art_Linzi/Fulllength.png" },
{ type: "copy", source: "Medium.png", destination: "Concept_Art_Linzi/Medium.png" },
{ type: "copy", source: "Small.png", destination: "Concept_Art_Linzi/Small.png" },
]);
});

it("takes only the immediate portrait folder regardless of nesting depth", async () => {
const result = await installPortrait(
["Mod/sub/Name/Fulllength.png", "Mod/sub/Name/Medium.png", "Mod/sub/Name/Small.png"],
"/staging/x",
);
expect(copies(result).map((c) => c.destination)).toEqual([
"Name/Fulllength.png",
"Name/Medium.png",
"Name/Small.png",
]);
});
});

describe("installVoice", () => {
it("flattens a single .bnk into the audio folder and tags the voice mod type", async () => {
const result = await installVoice(
["Vax (Madman)/PC_Male_Madman_GVR_ENG.bnk", "Readme.txt"],
"/staging/x.installing",
);
expect(modType(result)).toBe("voice");
expect(copies(result)).toEqual([
{
type: "copy",
source: "Vax (Madman)/PC_Male_Madman_GVR_ENG.bnk",
destination: "PC_Male_Madman_GVR_ENG.bnk",
},
]);
});

it("flattens several distinct banks regardless of nesting", async () => {
const result = await installVoice(["Voices/Angelina/A.bnk", "Voices/Chen/B.bnk"], "/staging/x");
expect(copies(result).map((c) => c.destination)).toEqual(["A.bnk", "B.bnk"]);
});

it("defers a multi-variant pack to the Mods folder with no voice mod type", async () => {
const result = await installVoice(
["magic goblin/PC_Male_Madman_GVR_ENG.bnk", "stabby goblin/PC_Male_Madman_GVR_ENG.bnk"],
"/staging/x",
);
expect(modType(result)).toBeUndefined();
expect(copies(result).map((c) => c.destination)).toEqual([
"magic goblin/PC_Male_Madman_GVR_ENG.bnk",
"stabby goblin/PC_Male_Madman_GVR_ENG.bnk",
]);
});
});
122 changes: 122 additions & 0 deletions extensions/games/game-pathfinderkingmaker/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Imperative install hooks for the Pathfinder: Kingmaker GDL extension.
*
* Two mod shapes need logic GDL cannot express declaratively, and both deploy
* outside the game's Mods folder (portraits to the user's LocalLow profile,
* voice banks to the game's audio folder). Doing the placement in a hook also
* keeps the install free of context-binding interpolation, which only resolves
* once Vortex has run the game's setup.
*
* - installPortrait: a portrait is a folder holding the Small/Medium/Fulllength
* PNG trio. Archives ship them as a single named folder, several folders
* under a Portraits/ wrapper, or loose at the archive root with no folder.
* The hook finds every trio folder and deploys each as Portraits/<name>/,
* synthesising a name from the mod for the loose-at-root case.
* - installVoice: Wwise .bnk soundbanks that overwrite the game's banks. The
* hook flattens every .bnk into the audio folder. Packs that bundle several
* variants of the same bank are a "pick one" choice an installer cannot
* make, so those deploy to the Mods folder unchanged for the user to resolve.
*
* These hooks are game-agnostic; the Wrath of the Righteous extension uses an
* identical copy. The per-game audio and portrait paths live in game.yaml.
*/
import { types } from "@nexusmods/vortex-api";

const PORTRAIT_MOD_TYPE = "portrait";
const VOICE_MOD_TYPE = "voice";

const isDir = (file: string): boolean => file.endsWith("/") || file.endsWith("\\");
const baseName = (file: string): string => file.split(/[\\/]/).pop() ?? file;
const dirName = (file: string): string => {
const norm = file.replace(/\\/g, "/");
const slash = norm.lastIndexOf("/");
return slash < 0 ? "" : norm.slice(0, slash);
};
const isImage = (file: string): boolean => /\.(png|jpe?g|webp)$/i.test(file);

/** Turn a staging-folder path into a stable, unique portrait subfolder name. */
function portraitFolderName(destinationPath: string): string {
const name = baseName(destinationPath)
.replace(/\.installing$/i, "")
.trim();
return name.length > 0 ? name : "Portrait";
}

/**
* Deploy every portrait folder (a directory holding the Small/Medium/Fulllength
* trio) as Portraits/<folder>/. Handles single-folder, multi-portrait wrapper,
* and loose-at-root archives at any nesting depth. Non-image files are dropped.
*/
export async function installPortrait(
files: string[],
destinationPath: string,
): Promise<types.IInstallResult> {
const byDir = new Map<string, string[]>();
for (const file of files) {
if (isDir(file)) continue;
const dir = dirName(file);
const list = byDir.get(dir) ?? [];
list.push(file);
byDir.set(dir, list);
}

const hasTrio = (dirFiles: string[]): boolean => {
const names = new Set(dirFiles.map((f) => baseName(f).toLowerCase()));
return names.has("fulllength.png") && names.has("medium.png") && names.has("small.png");
};

const copies: types.IInstruction[] = [];
for (const [dir, dirFiles] of byDir) {
if (!hasTrio(dirFiles)) continue;
const folder = dir === "" ? portraitFolderName(destinationPath) : baseName(dir);
for (const file of dirFiles) {
if (isImage(file)) {
copies.push({
type: "copy" as const,
source: file,
destination: `${folder}/${baseName(file)}`,
});
}
}
}

return {
instructions: [{ type: "setmodtype" as const, value: PORTRAIT_MOD_TYPE }, ...copies],
};
}

/**
* Voice soundbank: flatten every .bnk into the audio folder. If the archive
* bundles multiple variants of the same bank (the same .bnk filename under more
* than one folder), defer to the Mods folder by preserving the archive layout
* and leaving the mod type at its default, so the user can pick a variant.
*/
export async function installVoice(
files: string[],
_destinationPath: string,
): Promise<types.IInstallResult> {
const bnks = files.filter((file) => !isDir(file) && /\.bnk$/i.test(file));

const counts = new Map<string, number>();
for (const bnk of bnks) {
const key = baseName(bnk).toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const multiVariant = [...counts.values()].some((count) => count > 1);

if (multiVariant) {
const copies: types.IInstruction[] = files
.filter((file) => !isDir(file))
.map((file) => ({ type: "copy" as const, source: file, destination: file }));
return { instructions: copies };
}

const copies: types.IInstruction[] = bnks.map((bnk) => ({
type: "copy" as const,
source: bnk,
destination: baseName(bnk),
}));
return {
instructions: [{ type: "setmodtype" as const, value: VOICE_MOD_TYPE }, ...copies],
};
}
Loading