init
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
@@ -0,0 +1,167 @@
|
||||
# Cyberpunk 2077 Vortex extension for Linux (Proton / SteamTinkerLaunch)
|
||||
|
||||

|
||||
|
||||
A Vortex game extension for Cyberpunk 2077 that works when Vortex itself runs
|
||||
inside a Wine/Proton prefix, e.g. launched through SteamTinkerLaunch on Linux
|
||||
Mint.
|
||||
|
||||
## Why the community extension crashes here
|
||||
|
||||
The official/community extension discovers the game with Vortex's
|
||||
`GameStoreHelper.findByAppId(...)`. Inside a Proton prefix that helper reads the
|
||||
Windows Steam client's config:
|
||||
|
||||
```
|
||||
ENOENT: no such file or directory, open 'C:\Program Files (x86)\Steam\config\libraryfolders.vdf'
|
||||
```
|
||||
|
||||
SteamTinkerLaunch puts `steamclient.dll` and friends into
|
||||
`C:\Program Files (x86)\Steam` inside the prefix, but never writes
|
||||
`config\libraryfolders.vdf` — the real Steam libraries are native Linux
|
||||
directories such as `/home/<user>/.steam/steam` and `/mnt/<disk>/SteamLibrary`.
|
||||
The read throws, nothing catches it, and Vortex reports it as
|
||||
`unrecoverable error` in the renderer, taking the whole UI down.
|
||||
|
||||
This extension fixes both halves of that:
|
||||
|
||||
1. **Own discovery.** It never calls `GameStoreHelper`. It reads the real Linux
|
||||
Steam configuration through Wine's `Z:` drive (which maps `/`), parses
|
||||
`libraryfolders.vdf` and `appmanifest_1091500.acf`, and verifies
|
||||
`bin/x64/Cyberpunk2077.exe` before reporting a game path.
|
||||
2. **Prefix repair.** It recreates the missing
|
||||
`C:\Program Files (x86)\Steam\config\libraryfolders.vdf` inside the prefix,
|
||||
pointing at the real libraries as `Z:\...` paths. That stops Vortex core's own
|
||||
Steam scan from crashing, which also unbreaks other extensions. An existing
|
||||
file is never overwritten unless you ask for it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Vortex running in a Wine/Proton prefix (tested with the SteamTinkerLaunch
|
||||
Vortex prefix at `~/.config/steamtinkerlaunch/vortex/compatdata/pfx`)
|
||||
- Node.js 22+ on the Linux side, to build and install
|
||||
- Cyberpunk 2077 installed through Steam
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
tools/install-extension.sh
|
||||
```
|
||||
|
||||
The script builds `dist/`, copies it to
|
||||
`<prefix>/drive_c/users/steamuser/AppData/Roaming/Vortex/plugins/game-cyberpunk2077-linux/`,
|
||||
and repairs the prefix's `libraryfolders.vdf`. Restart Vortex afterwards and
|
||||
manage **Cyberpunk 2077 (Linux/Proton)**.
|
||||
|
||||
Options:
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `--prefix <path>` | Use a different Wine prefix (default: the SteamTinkerLaunch Vortex prefix) |
|
||||
| `--force-shim` | Rewrite `libraryfolders.vdf` even if one already exists |
|
||||
| `--disable-bundled` | Rename Vortex's bundled `game-cyberpunk2077` stub to `.disabled`, so only this extension offers Cyberpunk |
|
||||
|
||||
Repairing the prefix alone, without installing the extension:
|
||||
|
||||
```bash
|
||||
node scripts/repair-prefix.mjs --dry-run # show what would be written
|
||||
node scripts/repair-prefix.mjs # write it
|
||||
```
|
||||
|
||||
Run the repair *before* starting Vortex if Vortex currently dies on launch.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
rm -rf "$HOME/.config/steamtinkerlaunch/vortex/compatdata/pfx/drive_c/users/steamuser/AppData/Roaming/Vortex/plugins/game-cyberpunk2077-linux"
|
||||
```
|
||||
|
||||
The `libraryfolders.vdf` shim can stay — Vortex core needs it. Delete it with:
|
||||
|
||||
```bash
|
||||
rm "$HOME/.config/steamtinkerlaunch/vortex/compatdata/pfx/drive_c/Program Files (x86)/Steam/config/libraryfolders.vdf"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set `CYBERPUNK2077_PATH` to a Linux path to skip Steam discovery entirely, for
|
||||
example a GOG copy:
|
||||
|
||||
```
|
||||
CYBERPUNK2077_PATH=/mnt/games/GOG/Cyberpunk 2077
|
||||
```
|
||||
|
||||
The variable has to be visible to the Vortex process inside the prefix, so set it
|
||||
in the SteamTinkerLaunch launch options for Vortex, not just in your shell.
|
||||
|
||||
## Game id and Nexus downloads
|
||||
|
||||
The extension registers the game id `cyberpunk2077linux`, deliberately different
|
||||
from the bundled `cyberpunk2077` stub so the two can coexist. `nexusPageId` is
|
||||
still `cyberpunk2077`, so mod pages and `nxm://` downloads from the Cyberpunk
|
||||
Nexus section resolve normally.
|
||||
|
||||
## Mod layouts handled
|
||||
|
||||
The installer routes archive contents to the right place and strips wrapper
|
||||
folders such as `Cool Mod v1.2/`:
|
||||
|
||||
| Archive contains | Installed to |
|
||||
| --- | --- |
|
||||
| a game-root tree (`archive/`, `bin/`, `r6/`, `red4ext/`, `mods/`, `engine/`) | the game directory, wrapper folder stripped |
|
||||
| loose `.archive` / `.archive.xl` files | `archive\pc\mod\` |
|
||||
| `init.lua` plus its folder | `bin\x64\plugins\cyber_engine_tweaks\mods\<folder>\` |
|
||||
| loose `.reds` files | `r6\scripts\<mod name>\` |
|
||||
| `info.json` plus `archives/` (REDmod) | `mods\<folder>\` |
|
||||
| a bare `.dll` | `red4ext\plugins\<mod name>\` |
|
||||
| anything else | copied verbatim, so nothing is silently dropped |
|
||||
|
||||
Files that sit beside a detected game-root tree (readmes, screenshots) are left
|
||||
out of the deployment.
|
||||
|
||||
## Launching the game
|
||||
|
||||
Start Cyberpunk 2077 from Steam as usual. Vortex runs in its own prefix, so its
|
||||
"launch game" button would start the executable in the wrong prefix; the
|
||||
extension registers the executable for detection only.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test # unit tests (vitest)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run build # bundle to dist/
|
||||
node scripts/smoke-load.cjs # load the built bundle with a stubbed vortex-api
|
||||
```
|
||||
|
||||
The logic is split so it can be tested without Vortex or Wine:
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/vdf.ts` | Valve KeyValues parser/serialiser |
|
||||
| `src/winePath.ts` | Linux ⇄ `Z:` path translation, separator-aware joins |
|
||||
| `src/discovery.ts` | Steam roots, library folders, app install dir, game verification |
|
||||
| `src/steamShim.ts` | Builds and writes the `libraryfolders.vdf` shim |
|
||||
| `src/installer.ts` | Maps a mod archive's file list to install instructions |
|
||||
| `src/gameart.ts` | Generates the tile artwork and encodes it as a PNG |
|
||||
| `src/index.ts` | Vortex registration glue (the only file that touches `vortex-api`) |
|
||||
|
||||
Regenerating the artwork after editing `src/gameart.ts`:
|
||||
|
||||
```bash
|
||||
npm run gameart # rewrites assets/gameart.png
|
||||
```
|
||||
|
||||
`npm run build` renders the same image straight into `dist/`, so the two never
|
||||
drift; the render is deterministic, so rebuilds produce byte-identical output.
|
||||
|
||||
## Licence and trademarks
|
||||
|
||||
Public domain, released under the [Unlicense](https://unlicense.org/) - see
|
||||
`LICENSE`. Copy it, change it, ship it, sell it, no attribution needed.
|
||||
|
||||
Cyberpunk 2077 is a trademark of CD PROJEKT S.A.; this is an unofficial modding
|
||||
tool with no affiliation to CD PROJEKT S.A. or Nexus Mods. No game or Nexus
|
||||
assets are redistributed here - the tile artwork is generated by
|
||||
`src/gameart.ts` and is covered by the same public domain dedication.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Generated
+2081
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "game-cyberpunk2077-linux",
|
||||
"version": "1.0.0",
|
||||
"description": "Vortex game extension for Cyberpunk 2077 that discovers the Steam install through the Wine Z: drive instead of the Windows Steam registry, for Vortex running under Proton/SteamTinkerLaunch on Linux.",
|
||||
"license": "Unlicense",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node scripts/build.mjs",
|
||||
"gameart": "node scripts/make-gameart.mjs",
|
||||
"install-ext": "bash tools/install-extension.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// About me: Bundles the extension into dist/ as the folder layout Vortex loads:
|
||||
// a single CommonJS index.js plus info.json and the game artwork. `vortex-api` is
|
||||
// left external because Vortex provides it to extensions at runtime.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
import { encodePng, renderGameArt } from '../src/gameart.ts';
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const distDir = resolve(projectRoot, 'dist');
|
||||
const pkg = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8'));
|
||||
|
||||
mkdirSync(distDir, { recursive: true });
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [resolve(projectRoot, 'src/index.ts')],
|
||||
outfile: resolve(distDir, 'index.js'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
external: ['vortex-api'],
|
||||
legalComments: 'none',
|
||||
});
|
||||
|
||||
// The bundle is CommonJS, but this repo is an ESM package; without this marker
|
||||
// node would read dist/index.js as ESM and fail on `module.exports`.
|
||||
writeFileSync(resolve(distDir, 'package.json'), `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`);
|
||||
|
||||
const info = {
|
||||
name: 'Game: Cyberpunk 2077 (Linux/Proton)',
|
||||
author: 'local',
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
gameId: 'cyberpunk2077linux',
|
||||
};
|
||||
writeFileSync(resolve(distDir, 'info.json'), `${JSON.stringify(info, null, 2)}\n`);
|
||||
|
||||
// The tile artwork is generated, never copied from Vortex or Nexus, so the build
|
||||
// renders it straight from source. It is deterministic, so this is reproducible.
|
||||
writeFileSync(resolve(distDir, 'gameart.png'), encodePng(renderGameArt()));
|
||||
|
||||
console.log(`built ${resolve(distDir, 'index.js')}`);
|
||||
@@ -0,0 +1,19 @@
|
||||
// About me: Writes assets/gameart.png from the generated artwork in src/gameart.ts.
|
||||
// Kept as a separate step from the build so the committed image only changes when
|
||||
// someone deliberately regenerates it.
|
||||
//
|
||||
// Usage: node scripts/make-gameart.mjs [outputPath]
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { encodePng, renderGameArt } from '../src/gameart.ts';
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const output = resolve(projectRoot, process.argv[2] ?? 'assets/gameart.png');
|
||||
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
const png = encodePng(renderGameArt());
|
||||
writeFileSync(output, png);
|
||||
|
||||
console.log(`wrote ${output} (${png.length} bytes)`);
|
||||
@@ -0,0 +1,118 @@
|
||||
// About me: Writes the missing Steam libraryfolders.vdf into a Wine prefix from
|
||||
// the Linux side, before Vortex is ever started. Vortex core scans Steam at
|
||||
// startup and reads C:\Program Files (x86)\Steam\config\libraryfolders.vdf; when
|
||||
// SteamTinkerLaunch has not created it, the unhandled ENOENT shows up as
|
||||
// "unrecoverable error" and Vortex dies. Running this once repairs the prefix.
|
||||
//
|
||||
// Usage: node scripts/repair-prefix.mjs [--prefix <pfx>] [--force] [--dry-run]
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { findSteamLibraries, findSteamRoots } from '../src/discovery.ts';
|
||||
import {
|
||||
PREFIX_STEAM_DIR,
|
||||
buildShimVdf,
|
||||
dedupeByCanonicalPath,
|
||||
ensureSteamShim,
|
||||
} from '../src/steamShim.ts';
|
||||
|
||||
const DEFAULT_PREFIX = resolve(homedir(), '.config/steamtinkerlaunch/vortex/compatdata/pfx');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { prefix: DEFAULT_PREFIX, force: false, dryRun: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--prefix') {
|
||||
args.prefix = resolve(argv[i + 1] ?? '');
|
||||
i += 1;
|
||||
} else if (argv[i] === '--force') {
|
||||
args.force = true;
|
||||
} else if (argv[i] === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${argv[i]}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the paths Vortex would see inside the prefix to real Linux paths:
|
||||
* Z: is the Linux root, C: is the prefix's drive_c.
|
||||
*/
|
||||
function toRealPath(prefix, winPath) {
|
||||
const normalised = winPath.replace(/\\/g, '/');
|
||||
if (/^[zZ]:/.test(normalised)) {
|
||||
return normalised.slice(2) || '/';
|
||||
}
|
||||
if (/^[cC]:/.test(normalised)) {
|
||||
return resolve(prefix, 'drive_c', normalised.slice(3));
|
||||
}
|
||||
return normalised;
|
||||
}
|
||||
|
||||
function prefixFileAccess(prefix) {
|
||||
return {
|
||||
exists: (p) => existsSync(toRealPath(prefix, p)),
|
||||
readFile: (p) => readFileSync(toRealPath(prefix, p), 'utf8'),
|
||||
readDir: (p) => {
|
||||
try {
|
||||
return readdirSync(toRealPath(prefix, p));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!existsSync(args.prefix)) {
|
||||
console.error(`prefix not found: ${args.prefix}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = prefixFileAccess(args.prefix);
|
||||
|
||||
// Only the Wine-side (Z:) view is useful here: the shim has to contain paths
|
||||
// Vortex can open from inside the prefix.
|
||||
const libraries = dedupeByCanonicalPath(
|
||||
[
|
||||
...new Set(
|
||||
findSteamRoots(files)
|
||||
.filter((root) => root.startsWith('Z:'))
|
||||
.flatMap((root) => findSteamLibraries(files, root)),
|
||||
),
|
||||
],
|
||||
(candidate) => realpathSync(toRealPath(args.prefix, candidate)),
|
||||
);
|
||||
|
||||
console.log(`prefix: ${args.prefix}`);
|
||||
console.log(`steam dir: ${toRealPath(args.prefix, PREFIX_STEAM_DIR)}`);
|
||||
console.log(`libraries: ${libraries.length === 0 ? '(none found)' : ''}`);
|
||||
for (const library of libraries) {
|
||||
console.log(` ${library}`);
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('--- shim that would be written ---');
|
||||
console.log(buildShimVdf(libraries));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const writer = {
|
||||
makeDir: (p) => mkdirSync(toRealPath(args.prefix, p), { recursive: true }),
|
||||
writeFile: (p, contents) => writeFileSync(toRealPath(args.prefix, p), contents, 'utf8'),
|
||||
};
|
||||
|
||||
const result = ensureSteamShim(files, writer, libraries, { overwrite: args.force });
|
||||
console.log(`result: ${result.action} -> ${toRealPath(args.prefix, result.path)}`);
|
||||
|
||||
if (result.action === 'skipped') {
|
||||
console.error(
|
||||
libraries.length === 0
|
||||
? 'no Steam libraries found under /home; pass --prefix for the right prefix or check your Steam install'
|
||||
: `no Steam directory at ${toRealPath(args.prefix, PREFIX_STEAM_DIR)}; is this the Vortex prefix?`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// About me: Loads the built bundle the way Vortex does - require() with a stubbed
|
||||
// `vortex-api` - and drives registerGame/registerInstaller against a fake context.
|
||||
// This catches packaging mistakes (bad export shape, missing external) that unit
|
||||
// tests on the source cannot see. Run with: node scripts/smoke-load.cjs
|
||||
const Module = require('node:module');
|
||||
const path = require('node:path');
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'vortex-api') {
|
||||
return { log: (level, message) => console.log(` [vortex-api ${level}] ${message}`) };
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
const extension = require(path.resolve(__dirname, '../dist/index.js'));
|
||||
const main = extension.default ?? extension;
|
||||
|
||||
const registered = { games: [], installers: [] };
|
||||
const context = {
|
||||
registerGame: (game) => registered.games.push(game),
|
||||
registerInstaller: (id, priority, testSupported, install) =>
|
||||
registered.installers.push({ id, priority, testSupported, install }),
|
||||
once: (cb) => cb(),
|
||||
api: {},
|
||||
};
|
||||
|
||||
const result = main(context);
|
||||
console.log('main() returned:', result);
|
||||
|
||||
const game = registered.games[0];
|
||||
console.log('game id:', game.id);
|
||||
console.log('executable:', game.executable());
|
||||
console.log('queryModPath:', JSON.stringify(game.queryModPath('')));
|
||||
console.log('queryPath():', game.queryPath());
|
||||
|
||||
const installer = registered.installers[0];
|
||||
installer
|
||||
.testSupported(['a.archive'], game.id)
|
||||
.then((supported) => {
|
||||
console.log('testSupported:', JSON.stringify(supported));
|
||||
return installer.install(
|
||||
['Cool Mod v1\\archive\\pc\\mod\\cool.archive', 'Cool Mod v1\\readme.txt'],
|
||||
'Z:\\staging\\Cool Mod-1234.installing',
|
||||
game.id,
|
||||
);
|
||||
})
|
||||
.then((res) => {
|
||||
console.log('install instructions:', JSON.stringify(res.instructions, null, 2));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('smoke failed:', err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// About me: Tests for locating the Cyberpunk 2077 install by reading the native
|
||||
// Linux Steam config through the Wine Z: drive. This is the code path that
|
||||
// replaces Vortex's GameStoreHelper, which crashes in a Proton prefix because
|
||||
// it reads C:\Program Files (x86)\Steam\config\libraryfolders.vdf.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CYBERPUNK_STEAM_APP_ID,
|
||||
discoverGame,
|
||||
findSteamLibraries,
|
||||
findSteamRoots,
|
||||
resolveAppInstallDir,
|
||||
} from './discovery.ts';
|
||||
import type { FileAccess } from './fileAccess.ts';
|
||||
|
||||
const LIBRARY_FOLDERS = `"libraryfolders"
|
||||
{
|
||||
\t"0"
|
||||
\t{
|
||||
\t\t"path"\t\t"/home/tester/.steam/debian-installation"
|
||||
\t}
|
||||
\t"1"
|
||||
\t{
|
||||
\t\t"path"\t\t"/mnt/games/SteamLibrary"
|
||||
\t}
|
||||
}`;
|
||||
|
||||
const APP_MANIFEST = `"AppState"
|
||||
{
|
||||
\t"appid"\t\t"1091500"
|
||||
\t"name"\t\t"Cyberpunk 2077"
|
||||
\t"installdir"\t\t"Cyberpunk 2077"
|
||||
}`;
|
||||
|
||||
/** Builds an in-memory FileAccess from a path -> contents map (dirs use ''). */
|
||||
function fakeFs(files: Record<string, string>): FileAccess {
|
||||
const keys = Object.keys(files);
|
||||
return {
|
||||
exists: (p) => keys.some((k) => k === p || k.startsWith(`${p}\\`)),
|
||||
readFile: (p) => {
|
||||
if (files[p] === undefined) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
|
||||
}
|
||||
return files[p];
|
||||
},
|
||||
readDir: (p) => {
|
||||
const prefix = `${p}\\`;
|
||||
const names = keys
|
||||
.filter((k) => k.startsWith(prefix))
|
||||
.map((k) => k.slice(prefix.length).split('\\')[0]);
|
||||
return [...new Set(names)];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FULL_SYSTEM = fakeFs({
|
||||
'Z:\\home\\tester\\.steam\\debian-installation\\config\\libraryfolders.vdf': LIBRARY_FOLDERS,
|
||||
'Z:\\mnt\\games\\SteamLibrary\\steamapps\\appmanifest_1091500.acf': APP_MANIFEST,
|
||||
'Z:\\mnt\\games\\SteamLibrary\\steamapps\\common\\Cyberpunk 2077\\bin\\x64\\Cyberpunk2077.exe': 'MZ',
|
||||
});
|
||||
|
||||
describe('findSteamRoots', () => {
|
||||
it('finds the Steam root of every user under /home', () => {
|
||||
expect(findSteamRoots(FULL_SYSTEM)).toEqual([
|
||||
'Z:\\home\\tester\\.steam\\debian-installation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds a flatpak Steam install', () => {
|
||||
const fs = fakeFs({
|
||||
'Z:\\home\\tester\\.var\\app\\com.valvesoftware.Steam\\data\\Steam\\config\\libraryfolders.vdf':
|
||||
LIBRARY_FOLDERS,
|
||||
});
|
||||
|
||||
expect(findSteamRoots(fs)).toEqual([
|
||||
'Z:\\home\\tester\\.var\\app\\com.valvesoftware.Steam\\data\\Steam',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when no Steam install exists', () => {
|
||||
expect(findSteamRoots(fakeFs({ 'Z:\\home\\tester\\Documents\\notes.txt': 'x' }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSteamLibraries', () => {
|
||||
it('returns the Steam root plus every library from libraryfolders.vdf, as Wine paths', () => {
|
||||
const libs = findSteamLibraries(FULL_SYSTEM, 'Z:\\home\\tester\\.steam\\debian-installation');
|
||||
|
||||
expect(libs).toEqual([
|
||||
'Z:\\home\\tester\\.steam\\debian-installation',
|
||||
'Z:\\mnt\\games\\SteamLibrary',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to steamapps/libraryfolders.vdf when config/ has none', () => {
|
||||
const fs = fakeFs({
|
||||
'Z:\\home\\tester\\.steam\\steam\\steamapps\\libraryfolders.vdf': LIBRARY_FOLDERS,
|
||||
});
|
||||
|
||||
expect(findSteamLibraries(fs, 'Z:\\home\\tester\\.steam\\steam')).toContain(
|
||||
'Z:\\mnt\\games\\SteamLibrary',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns just the root when no libraryfolders.vdf exists at all', () => {
|
||||
const fs = fakeFs({ 'Z:\\home\\tester\\.steam\\steam\\steam.sh': 'x' });
|
||||
|
||||
expect(findSteamLibraries(fs, 'Z:\\home\\tester\\.steam\\steam')).toEqual([
|
||||
'Z:\\home\\tester\\.steam\\steam',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAppInstallDir', () => {
|
||||
it('resolves the install dir from the app manifest', () => {
|
||||
expect(
|
||||
resolveAppInstallDir(FULL_SYSTEM, 'Z:\\mnt\\games\\SteamLibrary', CYBERPUNK_STEAM_APP_ID),
|
||||
).toBe('Z:\\mnt\\games\\SteamLibrary\\steamapps\\common\\Cyberpunk 2077');
|
||||
});
|
||||
|
||||
it('returns undefined when the library does not hold that app', () => {
|
||||
expect(
|
||||
resolveAppInstallDir(
|
||||
FULL_SYSTEM,
|
||||
'Z:\\home\\tester\\.steam\\debian-installation',
|
||||
CYBERPUNK_STEAM_APP_ID,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('native Linux filesystem (Vortex running outside a Wine prefix)', () => {
|
||||
const nativeFs: FileAccess = (() => {
|
||||
const files: Record<string, string> = {
|
||||
'/home/tester/.steam/debian-installation/config/libraryfolders.vdf': LIBRARY_FOLDERS,
|
||||
'/mnt/games/SteamLibrary/steamapps/appmanifest_1091500.acf': APP_MANIFEST,
|
||||
'/mnt/games/SteamLibrary/steamapps/common/Cyberpunk 2077/bin/x64/Cyberpunk2077.exe': 'MZ',
|
||||
};
|
||||
const keys = Object.keys(files);
|
||||
return {
|
||||
exists: (p) => keys.some((k) => k === p || k.startsWith(`${p}/`)),
|
||||
readFile: (p) => {
|
||||
if (files[p] === undefined) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
|
||||
}
|
||||
return files[p];
|
||||
},
|
||||
readDir: (p) => [
|
||||
...new Set(
|
||||
keys.filter((k) => k.startsWith(`${p}/`)).map((k) => k.slice(p.length + 1).split('/')[0]),
|
||||
),
|
||||
],
|
||||
};
|
||||
})();
|
||||
|
||||
it('finds Steam roots under the real /home', () => {
|
||||
expect(findSteamRoots(nativeFs)).toContain('/home/tester/.steam/debian-installation');
|
||||
});
|
||||
|
||||
it('discovers the game with native paths', () => {
|
||||
expect(discoverGame(nativeFs)).toBe(
|
||||
'/mnt/games/SteamLibrary/steamapps/common/Cyberpunk 2077',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverGame', () => {
|
||||
it('finds the game directory end to end', () => {
|
||||
expect(discoverGame(FULL_SYSTEM)).toBe(
|
||||
'Z:\\mnt\\games\\SteamLibrary\\steamapps\\common\\Cyberpunk 2077',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an install dir that has no game executable', () => {
|
||||
const fs = fakeFs({
|
||||
'Z:\\home\\tester\\.steam\\debian-installation\\config\\libraryfolders.vdf': LIBRARY_FOLDERS,
|
||||
'Z:\\mnt\\games\\SteamLibrary\\steamapps\\appmanifest_1091500.acf': APP_MANIFEST,
|
||||
});
|
||||
|
||||
expect(discoverGame(fs)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers an explicit override path when it holds the executable', () => {
|
||||
const fs = fakeFs({
|
||||
'Z:\\mnt\\other\\Cyberpunk 2077\\bin\\x64\\Cyberpunk2077.exe': 'MZ',
|
||||
});
|
||||
|
||||
expect(discoverGame(fs, { overridePath: '/mnt/other/Cyberpunk 2077' })).toBe(
|
||||
'Z:\\mnt\\other\\Cyberpunk 2077',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores an override that does not contain the executable', () => {
|
||||
expect(discoverGame(FULL_SYSTEM, { overridePath: '/mnt/wrong' })).toBe(
|
||||
'Z:\\mnt\\games\\SteamLibrary\\steamapps\\common\\Cyberpunk 2077',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is installed', () => {
|
||||
expect(discoverGame(fakeFs({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// About me: Finds the Cyberpunk 2077 install without touching Vortex's
|
||||
// GameStoreHelper. Inside a Proton prefix that helper reads
|
||||
// C:\Program Files (x86)\Steam\config\libraryfolders.vdf, which SteamTinkerLaunch
|
||||
// never creates, and the resulting ENOENT takes the whole Vortex UI down. Here we
|
||||
// read the real Linux Steam config instead, through the Wine Z: drive when Vortex
|
||||
// runs in a prefix and directly when it runs natively.
|
||||
import type { FileAccess } from './fileAccess.ts';
|
||||
import { parseVdf, type VdfNode } from './vdf.ts';
|
||||
import { joinPath, toWinePath } from './winePath.ts';
|
||||
|
||||
/** Steam app id of Cyberpunk 2077. */
|
||||
export const CYBERPUNK_STEAM_APP_ID = '1091500';
|
||||
|
||||
/** Executable that proves a directory really is a Cyberpunk 2077 install. */
|
||||
export const GAME_EXECUTABLE = 'bin\\x64\\Cyberpunk2077.exe';
|
||||
|
||||
/** Steam install layouts, relative to a user's Linux home directory. */
|
||||
const STEAM_ROOT_SUFFIXES = [
|
||||
'.steam\\steam',
|
||||
'.steam\\root',
|
||||
'.steam\\debian-installation',
|
||||
'.local\\share\\Steam',
|
||||
'.var\\app\\com.valvesoftware.Steam\\data\\Steam',
|
||||
];
|
||||
|
||||
/** Places a libraryfolders.vdf can live inside a Steam root. */
|
||||
const LIBRARY_FOLDERS_LOCATIONS = ['config\\libraryfolders.vdf', 'steamapps\\libraryfolders.vdf'];
|
||||
|
||||
export interface DiscoveryOptions {
|
||||
/** Linux path to a manually specified game directory, checked before Steam. */
|
||||
overridePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where user home directories live. Vortex normally runs inside a Proton prefix
|
||||
* and sees the Linux filesystem on Z:, but the native Linux build sees it
|
||||
* directly, so both spellings are probed.
|
||||
*/
|
||||
const HOME_ROOTS = [toWinePath('/home'), '/home'];
|
||||
|
||||
/** True for drive-letter paths, i.e. paths seen from inside a Wine prefix. */
|
||||
function isWinePath(candidate: string): boolean {
|
||||
return /^[a-zA-Z]:/.test(candidate);
|
||||
}
|
||||
|
||||
function homeDirectories(files: FileAccess): string[] {
|
||||
return HOME_ROOTS.flatMap((homeRoot) =>
|
||||
files.readDir(homeRoot).map((name) => joinPath(homeRoot, name)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Locates every native Linux Steam installation, in Wine and native path form. */
|
||||
export function findSteamRoots(files: FileAccess): string[] {
|
||||
const roots: string[] = [];
|
||||
|
||||
for (const home of homeDirectories(files)) {
|
||||
for (const suffix of STEAM_ROOT_SUFFIXES) {
|
||||
const root = joinPath(home, suffix);
|
||||
const hasLibraryFolders = LIBRARY_FOLDERS_LOCATIONS.some((location) =>
|
||||
files.exists(joinPath(root, location)),
|
||||
);
|
||||
if (hasLibraryFolders && !roots.includes(root)) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
function readLibraryFolders(files: FileAccess, steamRoot: string): VdfNode | undefined {
|
||||
for (const location of LIBRARY_FOLDERS_LOCATIONS) {
|
||||
const path = joinPath(steamRoot, location);
|
||||
if (!files.exists(path)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return parseVdf(files.readFile(path));
|
||||
} catch {
|
||||
// A corrupt vdf must not abort discovery; fall through to the next location.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Lists the Steam root and every extra library folder it declares, in the root's path style. */
|
||||
export function findSteamLibraries(files: FileAccess, steamRoot: string): string[] {
|
||||
const libraries = [steamRoot];
|
||||
const parsed = readLibraryFolders(files, steamRoot);
|
||||
const folders = parsed?.libraryfolders;
|
||||
|
||||
if (typeof folders === 'object') {
|
||||
for (const entry of Object.values(folders)) {
|
||||
const path = typeof entry === 'object' ? entry.path : entry;
|
||||
if (typeof path !== 'string' || path === '') {
|
||||
continue;
|
||||
}
|
||||
// libraryfolders.vdf always stores native Linux paths; map them into the
|
||||
// prefix only when the caller is working with Wine paths.
|
||||
const libraryPath = isWinePath(steamRoot) ? toWinePath(path) : path;
|
||||
if (!libraries.includes(libraryPath)) {
|
||||
libraries.push(libraryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
/** Reads appmanifest_<appId>.acf in a library and returns the app's install directory. */
|
||||
export function resolveAppInstallDir(
|
||||
files: FileAccess,
|
||||
library: string,
|
||||
appId: string,
|
||||
): string | undefined {
|
||||
const manifest = joinPath(library, 'steamapps', `appmanifest_${appId}.acf`);
|
||||
if (!files.exists(manifest)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let installDir: string | undefined;
|
||||
try {
|
||||
const state = parseVdf(files.readFile(manifest)).AppState;
|
||||
if (typeof state === 'object' && typeof state.installdir === 'string') {
|
||||
installDir = state.installdir;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (installDir === undefined || installDir === '') {
|
||||
return undefined;
|
||||
}
|
||||
return joinPath(library, 'steamapps', 'common', installDir);
|
||||
}
|
||||
|
||||
function isGameDirectory(files: FileAccess, candidate: string): boolean {
|
||||
return files.exists(joinPath(candidate, GAME_EXECUTABLE));
|
||||
}
|
||||
|
||||
/** Returns the path of the Cyberpunk 2077 install, or undefined if not found. */
|
||||
export function discoverGame(files: FileAccess, options: DiscoveryOptions = {}): string | undefined {
|
||||
if (options.overridePath !== undefined && options.overridePath !== '') {
|
||||
// Accept the override as given or mapped onto Z:, so the same setting works
|
||||
// whether Vortex runs in a prefix or natively.
|
||||
for (const candidate of [toWinePath(options.overridePath), options.overridePath]) {
|
||||
if (isGameDirectory(files, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of findSteamRoots(files)) {
|
||||
for (const library of findSteamLibraries(files, root)) {
|
||||
const installDir = resolveAppInstallDir(files, library, CYBERPUNK_STEAM_APP_ID);
|
||||
if (installDir !== undefined && isGameDirectory(files, installDir)) {
|
||||
return installDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// About me: The tiny synchronous filesystem interface the discovery code needs.
|
||||
// Keeping it behind an interface lets the logic be unit-tested with an in-memory
|
||||
// filesystem, while the extension passes a node:fs backed implementation.
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export interface FileAccess {
|
||||
exists(path: string): boolean;
|
||||
readFile(path: string): string;
|
||||
readDir(path: string): string[];
|
||||
}
|
||||
|
||||
/** FileAccess backed by node:fs, with directory listing errors reported as empty. */
|
||||
export const nodeFileAccess: FileAccess = {
|
||||
exists: (path) => fs.existsSync(path),
|
||||
readFile: (path) => fs.readFileSync(path, 'utf8'),
|
||||
readDir: (path) => {
|
||||
try {
|
||||
return fs.readdirSync(path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
// About me: Tests for the generated tile artwork. The image has to be produced
|
||||
// from scratch (no game or Nexus assets may be redistributed with this project),
|
||||
// so both the PNG encoder and the drawing primitives are covered here.
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GAME_ART_HEIGHT,
|
||||
GAME_ART_WIDTH,
|
||||
createCanvas,
|
||||
crc32,
|
||||
drawText,
|
||||
encodePng,
|
||||
renderGameArt,
|
||||
} from './gameart.ts';
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
describe('crc32', () => {
|
||||
it('matches the reference check value', () => {
|
||||
expect(crc32(Buffer.from('123456789'))).toBe(0xcbf43926);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodePng', () => {
|
||||
it('writes a PNG signature and an IHDR with the right dimensions', () => {
|
||||
const png = encodePng(createCanvas(4, 3));
|
||||
|
||||
expect(png.subarray(0, 8)).toEqual(PNG_SIGNATURE);
|
||||
expect(png.subarray(12, 16).toString('latin1')).toBe('IHDR');
|
||||
expect(png.readUInt32BE(16)).toBe(4);
|
||||
expect(png.readUInt32BE(20)).toBe(3);
|
||||
expect(png.subarray(png.length - 8, png.length - 4).toString('latin1')).toBe('IEND');
|
||||
});
|
||||
|
||||
it('stores every scanline with a filter byte and RGB samples', () => {
|
||||
const canvas = createCanvas(2, 2);
|
||||
const idatStart = png_findIdat(encodePng(canvas));
|
||||
const png = encodePng(canvas);
|
||||
const length = png.readUInt32BE(idatStart);
|
||||
const raw = inflateSync(png.subarray(idatStart + 8, idatStart + 8 + length));
|
||||
|
||||
expect(raw.length).toBe(2 * (1 + 2 * 3));
|
||||
expect(raw[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Finds the offset of the IDAT chunk header in a PNG buffer. */
|
||||
function png_findIdat(png: Buffer): number {
|
||||
let offset = 8;
|
||||
while (offset < png.length) {
|
||||
const length = png.readUInt32BE(offset);
|
||||
if (png.subarray(offset + 4, offset + 8).toString('latin1') === 'IDAT') {
|
||||
return offset;
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
throw new Error('no IDAT chunk');
|
||||
}
|
||||
|
||||
describe('drawText', () => {
|
||||
it('lights up pixels for a known glyph', () => {
|
||||
const canvas = createCanvas(10, 10);
|
||||
drawText(canvas, 'I', 1, 1, 1, [255, 255, 255]);
|
||||
|
||||
expect([...canvas.data].some((value) => value > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the canvas untouched for a space', () => {
|
||||
const canvas = createCanvas(10, 10);
|
||||
drawText(canvas, ' ', 1, 1, 1, [255, 255, 255]);
|
||||
|
||||
expect([...canvas.data].every((value) => value === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores characters that have no glyph instead of throwing', () => {
|
||||
const canvas = createCanvas(10, 10);
|
||||
|
||||
expect(() => drawText(canvas, '@', 1, 1, 1, [255, 255, 255])).not.toThrow();
|
||||
});
|
||||
|
||||
it('clips drawing at the canvas edges', () => {
|
||||
const canvas = createCanvas(4, 4);
|
||||
|
||||
expect(() => drawText(canvas, 'X', 3, 3, 4, [255, 255, 255])).not.toThrow();
|
||||
expect(canvas.data.length).toBe(4 * 4 * 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderGameArt', () => {
|
||||
it('produces a canvas of the tile size', () => {
|
||||
const canvas = renderGameArt();
|
||||
|
||||
expect(canvas.width).toBe(GAME_ART_WIDTH);
|
||||
expect(canvas.height).toBe(GAME_ART_HEIGHT);
|
||||
expect(canvas.data.length).toBe(GAME_ART_WIDTH * GAME_ART_HEIGHT * 3);
|
||||
});
|
||||
|
||||
it('is deterministic, so rebuilds do not churn the committed image', () => {
|
||||
expect(Buffer.from(renderGameArt().data)).toEqual(Buffer.from(renderGameArt().data));
|
||||
});
|
||||
|
||||
it('actually draws something: many distinct colours, not a flat fill', () => {
|
||||
const { data } = renderGameArt();
|
||||
const colours = new Set<string>();
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
colours.add(`${data[i]},${data[i + 1]},${data[i + 2]}`);
|
||||
}
|
||||
|
||||
expect(colours.size).toBeGreaterThan(200);
|
||||
});
|
||||
});
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
// About me: Draws the extension's tile artwork from scratch and encodes it as a
|
||||
// PNG, with no image library and no borrowed assets. The project ships publicly,
|
||||
// so it must not redistribute Nexus or CD PROJEKT artwork; this generates an
|
||||
// original synthwave skyline instead. Everything is deterministic so rebuilding
|
||||
// never produces a different file.
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
/** Tile size Vortex shows for a game, matching the 16:9 art it expects. */
|
||||
export const GAME_ART_WIDTH = 800;
|
||||
export const GAME_ART_HEIGHT = 450;
|
||||
|
||||
export type Rgb = [number, number, number];
|
||||
|
||||
export interface Canvas {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Row-major RGB samples, 3 bytes per pixel. */
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export function createCanvas(width: number, height: number): Canvas {
|
||||
return { width, height, data: new Uint8Array(width * height * 3) };
|
||||
}
|
||||
|
||||
function setPixel(canvas: Canvas, x: number, y: number, colour: Rgb): void {
|
||||
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) {
|
||||
return;
|
||||
}
|
||||
const offset = (y * canvas.width + x) * 3;
|
||||
canvas.data[offset] = clampByte(colour[0]);
|
||||
canvas.data[offset + 1] = clampByte(colour[1]);
|
||||
canvas.data[offset + 2] = clampByte(colour[2]);
|
||||
}
|
||||
|
||||
function getPixel(canvas: Canvas, x: number, y: number): Rgb {
|
||||
const offset = (y * canvas.width + x) * 3;
|
||||
return [canvas.data[offset], canvas.data[offset + 1], canvas.data[offset + 2]];
|
||||
}
|
||||
|
||||
/** Blends a colour onto a pixel; alpha 1 replaces it, 0 leaves it. */
|
||||
function blendPixel(canvas: Canvas, x: number, y: number, colour: Rgb, alpha: number): void {
|
||||
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height || alpha <= 0) {
|
||||
return;
|
||||
}
|
||||
const base = getPixel(canvas, x, y);
|
||||
setPixel(canvas, x, y, [
|
||||
base[0] + (colour[0] - base[0]) * alpha,
|
||||
base[1] + (colour[1] - base[1]) * alpha,
|
||||
base[2] + (colour[2] - base[2]) * alpha,
|
||||
]);
|
||||
}
|
||||
|
||||
function fillRect(canvas: Canvas, x: number, y: number, w: number, h: number, colour: Rgb): void {
|
||||
for (let dy = 0; dy < h; dy += 1) {
|
||||
for (let dx = 0; dx < w; dx += 1) {
|
||||
setPixel(canvas, x + dx, y + dy, colour);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function mix(a: Rgb, b: Rgb, t: number): Rgb {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
}
|
||||
|
||||
/** Small deterministic PRNG, so the artwork is byte-identical on every build. */
|
||||
function makeRandom(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
// A 5x7 pixel font, limited to the characters the artwork needs.
|
||||
const GLYPHS: Record<string, string[]> = {
|
||||
B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
|
||||
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
|
||||
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
|
||||
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
|
||||
N: ['10001', '11001', '11001', '10101', '10011', '10011', '10001'],
|
||||
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
|
||||
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
|
||||
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
|
||||
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
|
||||
Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
|
||||
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
|
||||
'7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
|
||||
'/': ['00001', '00001', '00010', '00100', '01000', '10000', '10000'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
};
|
||||
|
||||
export const GLYPH_WIDTH = 5;
|
||||
export const GLYPH_HEIGHT = 7;
|
||||
|
||||
/** Width in pixels that drawText will occupy for a string at the given scale. */
|
||||
export function textWidth(text: string, scale: number): number {
|
||||
return text.length * (GLYPH_WIDTH + 1) * scale - scale;
|
||||
}
|
||||
|
||||
/** Draws text with the built-in 5x7 font. Unknown characters are skipped. */
|
||||
export function drawText(
|
||||
canvas: Canvas,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
colour: Rgb,
|
||||
): void {
|
||||
let cursor = x;
|
||||
|
||||
for (const char of text.toUpperCase()) {
|
||||
const glyph = GLYPHS[char];
|
||||
if (glyph !== undefined) {
|
||||
glyph.forEach((row, rowIndex) => {
|
||||
for (let column = 0; column < row.length; column += 1) {
|
||||
if (row[column] !== '1') {
|
||||
continue;
|
||||
}
|
||||
fillRect(
|
||||
canvas,
|
||||
cursor + column * scale,
|
||||
y + rowIndex * scale,
|
||||
scale,
|
||||
scale,
|
||||
colour,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
cursor += (GLYPH_WIDTH + 1) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws text with a soft neon halo around it. */
|
||||
function drawNeonText(
|
||||
canvas: Canvas,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
colour: Rgb,
|
||||
glow: Rgb,
|
||||
): void {
|
||||
const offsets = [
|
||||
[-2, 0],
|
||||
[2, 0],
|
||||
[0, -2],
|
||||
[0, 2],
|
||||
[-1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
[1, -1],
|
||||
];
|
||||
const halo = createCanvas(canvas.width, canvas.height);
|
||||
|
||||
for (const [dx, dy] of offsets) {
|
||||
drawText(halo, text, x + dx, y + dy, scale, glow);
|
||||
}
|
||||
for (let py = 0; py < canvas.height; py += 1) {
|
||||
for (let px = 0; px < canvas.width; px += 1) {
|
||||
const [r, g, b] = getPixel(halo, px, py);
|
||||
if (r + g + b > 0) {
|
||||
blendPixel(canvas, px, py, [r, g, b], 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
drawText(canvas, text, x, y, scale, colour);
|
||||
}
|
||||
|
||||
const SKY_TOP: Rgb = [10, 6, 26];
|
||||
const SKY_HORIZON: Rgb = [86, 12, 78];
|
||||
const SUN_TOP: Rgb = [255, 216, 102];
|
||||
const SUN_BOTTOM: Rgb = [255, 44, 138];
|
||||
const GRID_COLOUR: Rgb = [0, 226, 255];
|
||||
const BUILDING_DARK: Rgb = [8, 6, 20];
|
||||
const TITLE_COLOUR: Rgb = [255, 240, 250];
|
||||
const TITLE_GLOW: Rgb = [255, 40, 140];
|
||||
const SUBTITLE_COLOUR: Rgb = [0, 226, 255];
|
||||
|
||||
/** Renders the tile: synthwave sky, sliced sun, neon grid, skyline and title. */
|
||||
export function renderGameArt(): Canvas {
|
||||
const canvas = createCanvas(GAME_ART_WIDTH, GAME_ART_HEIGHT);
|
||||
const random = makeRandom(0x5eed);
|
||||
const horizon = Math.round(GAME_ART_HEIGHT * 0.66);
|
||||
|
||||
// Sky gradient above the horizon, dark ground below it.
|
||||
for (let y = 0; y < GAME_ART_HEIGHT; y += 1) {
|
||||
const colour =
|
||||
y < horizon
|
||||
? mix(SKY_TOP, SKY_HORIZON, (y / horizon) ** 1.6)
|
||||
: mix([16, 4, 34], [4, 2, 10], (y - horizon) / (GAME_ART_HEIGHT - horizon));
|
||||
for (let x = 0; x < GAME_ART_WIDTH; x += 1) {
|
||||
setPixel(canvas, x, y, colour);
|
||||
}
|
||||
}
|
||||
|
||||
// Stars, thinning out towards the horizon.
|
||||
for (let i = 0; i < 260; i += 1) {
|
||||
const x = Math.floor(random() * GAME_ART_WIDTH);
|
||||
const y = Math.floor(random() * horizon * 0.8);
|
||||
const brightness = 0.25 + random() * 0.75;
|
||||
blendPixel(canvas, x, y, [255, 255, 255], brightness * (1 - y / horizon));
|
||||
}
|
||||
|
||||
// The sun, cut by horizontal slices in the lower half.
|
||||
const sunCentreX = GAME_ART_WIDTH / 2;
|
||||
const sunCentreY = horizon - 6;
|
||||
const sunRadius = 118;
|
||||
for (let y = sunCentreY - sunRadius; y <= sunCentreY + sunRadius; y += 1) {
|
||||
if (y >= horizon) {
|
||||
continue;
|
||||
}
|
||||
const halfWidth = Math.sqrt(Math.max(0, sunRadius ** 2 - (y - sunCentreY) ** 2));
|
||||
const t = (y - (sunCentreY - sunRadius)) / (2 * sunRadius);
|
||||
const sliceGap = y > sunCentreY - sunRadius * 0.35 && Math.floor((y - sunCentreY) / 9) % 2 === 0;
|
||||
if (sliceGap) {
|
||||
continue;
|
||||
}
|
||||
for (let x = Math.round(sunCentreX - halfWidth); x <= Math.round(sunCentreX + halfWidth); x += 1) {
|
||||
blendPixel(canvas, x, y, mix(SUN_TOP, SUN_BOTTOM, t), 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// Perspective grid on the ground plane.
|
||||
for (let x = -20; x <= 20; x += 1) {
|
||||
for (let y = horizon; y < GAME_ART_HEIGHT; y += 1) {
|
||||
const depth = (y - horizon) / (GAME_ART_HEIGHT - horizon);
|
||||
const px = Math.round(sunCentreX + x * 26 * (depth * depth * 3 + 0.02));
|
||||
blendPixel(canvas, px, y, GRID_COLOUR, 0.28 * (0.25 + depth));
|
||||
}
|
||||
}
|
||||
let gridY = horizon + 1;
|
||||
let step = 1.6;
|
||||
while (gridY < GAME_ART_HEIGHT) {
|
||||
const depth = (gridY - horizon) / (GAME_ART_HEIGHT - horizon);
|
||||
for (let x = 0; x < GAME_ART_WIDTH; x += 1) {
|
||||
blendPixel(canvas, x, Math.round(gridY), GRID_COLOUR, 0.3 * (0.2 + depth));
|
||||
}
|
||||
gridY += step;
|
||||
step *= 1.32;
|
||||
}
|
||||
|
||||
// Skyline silhouette with lit windows, denser towards the middle.
|
||||
let x = -10;
|
||||
while (x < GAME_ART_WIDTH) {
|
||||
const width = 26 + Math.floor(random() * 46);
|
||||
const distanceFromCentre = Math.abs(x + width / 2 - sunCentreX) / (GAME_ART_WIDTH / 2);
|
||||
// Shorter towers in the middle so the sun stays visible behind them.
|
||||
const height = Math.round((55 + random() * 120) * (0.72 + distanceFromCentre * 0.85));
|
||||
const top = horizon - height;
|
||||
fillRect(canvas, x, top, width, height, BUILDING_DARK);
|
||||
|
||||
// Neon roof line, alternating cyan and magenta.
|
||||
const roofColour: Rgb = random() > 0.5 ? [0, 226, 255] : [255, 44, 138];
|
||||
for (let rx = x; rx < x + width; rx += 1) {
|
||||
blendPixel(canvas, rx, top, roofColour, 0.75);
|
||||
blendPixel(canvas, rx, top - 1, roofColour, 0.25);
|
||||
}
|
||||
|
||||
for (let wy = top + 6; wy < horizon - 4; wy += 9) {
|
||||
for (let wx = x + 4; wx < x + width - 4; wx += 7) {
|
||||
if (random() > 0.55) {
|
||||
continue;
|
||||
}
|
||||
const lit: Rgb = random() > 0.75 ? [255, 214, 92] : [120, 220, 255];
|
||||
fillRect(canvas, wx, wy, 2, 3, lit);
|
||||
}
|
||||
}
|
||||
|
||||
x += width + 2 + Math.floor(random() * 10);
|
||||
}
|
||||
|
||||
// Title block.
|
||||
const title = 'CYBERPUNK 2077';
|
||||
const titleScale = 6;
|
||||
drawNeonText(
|
||||
canvas,
|
||||
title,
|
||||
Math.round((GAME_ART_WIDTH - textWidth(title, titleScale)) / 2),
|
||||
Math.round(GAME_ART_HEIGHT * 0.17),
|
||||
titleScale,
|
||||
TITLE_COLOUR,
|
||||
TITLE_GLOW,
|
||||
);
|
||||
|
||||
const subtitle = 'LINUX / PROTON';
|
||||
const subtitleScale = 3;
|
||||
const subtitleX = Math.round((GAME_ART_WIDTH - textWidth(subtitle, subtitleScale)) / 2);
|
||||
const subtitleY = Math.round(GAME_ART_HEIGHT * 0.17) + titleScale * GLYPH_HEIGHT + 18;
|
||||
drawText(canvas, subtitle, subtitleX, subtitleY, subtitleScale, SUBTITLE_COLOUR);
|
||||
|
||||
// Scanlines and a vignette to tie it together.
|
||||
for (let y = 0; y < GAME_ART_HEIGHT; y += 3) {
|
||||
for (let px = 0; px < GAME_ART_WIDTH; px += 1) {
|
||||
blendPixel(canvas, px, y, [0, 0, 0], 0.16);
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < GAME_ART_HEIGHT; y += 1) {
|
||||
for (let px = 0; px < GAME_ART_WIDTH; px += 1) {
|
||||
const dx = (px - GAME_ART_WIDTH / 2) / (GAME_ART_WIDTH / 2);
|
||||
const dy = (y - GAME_ART_HEIGHT / 2) / (GAME_ART_HEIGHT / 2);
|
||||
const vignette = Math.max(0, (dx * dx + dy * dy) * 0.42 - 0.1);
|
||||
blendPixel(canvas, px, y, [0, 0, 0], Math.min(0.75, vignette));
|
||||
}
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
/** CRC-32 as used by PNG chunks. */
|
||||
export function crc32(buffer: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, payload: Buffer): Buffer {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(payload.length);
|
||||
const typed = Buffer.concat([Buffer.from(type, 'latin1'), payload]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(typed));
|
||||
return Buffer.concat([length, typed, crc]);
|
||||
}
|
||||
|
||||
/** Encodes a canvas as an 8-bit RGB PNG. */
|
||||
export function encodePng(canvas: Canvas): Buffer {
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(canvas.width, 0);
|
||||
header.writeUInt32BE(canvas.height, 4);
|
||||
header[8] = 8; // bit depth
|
||||
header[9] = 2; // colour type: truecolour
|
||||
header[10] = 0; // deflate
|
||||
header[11] = 0; // adaptive filtering
|
||||
header[12] = 0; // no interlace
|
||||
|
||||
// One filter byte (0 = none) in front of every scanline.
|
||||
const stride = canvas.width * 3;
|
||||
const raw = Buffer.alloc(canvas.height * (stride + 1));
|
||||
for (let y = 0; y < canvas.height; y += 1) {
|
||||
raw[y * (stride + 1)] = 0;
|
||||
Buffer.from(canvas.data.subarray(y * stride, (y + 1) * stride)).copy(
|
||||
raw,
|
||||
y * (stride + 1) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
signature,
|
||||
chunk('IHDR', header),
|
||||
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
// About me: Entry point of the Vortex game extension for Cyberpunk 2077 on Linux.
|
||||
// It registers the game using its own Steam discovery (reading the native Linux
|
||||
// Steam config through the Wine Z: drive) instead of Vortex's GameStoreHelper,
|
||||
// which throws an unhandled ENOENT inside a SteamTinkerLaunch prefix, and it
|
||||
// repairs the missing libraryfolders.vdf that causes that crash for Vortex core.
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { log as vortexLog } from 'vortex-api';
|
||||
|
||||
import { CYBERPUNK_STEAM_APP_ID, discoverGame, findSteamLibraries, findSteamRoots } from './discovery.ts';
|
||||
import { nodeFileAccess } from './fileAccess.ts';
|
||||
import { buildInstructions, modNameFromStagingPath } from './installer.ts';
|
||||
import { dedupeByCanonicalPath, ensureSteamShim, type FileWriter } from './steamShim.ts';
|
||||
import type { ExtensionContext, InstallResult, SupportedResult } from './vortexApi.ts';
|
||||
import { joinWin } from './winePath.ts';
|
||||
|
||||
/** Distinct from the bundled `cyberpunk2077` stub so both can coexist in Vortex. */
|
||||
export const GAME_ID = 'cyberpunk2077linux';
|
||||
|
||||
/** Game executable, relative to the game directory. */
|
||||
export const GAME_EXE = 'bin/x64/Cyberpunk2077.exe';
|
||||
|
||||
/** Directories mods deploy into; created up front so deployment never fails on a missing target. */
|
||||
export const MOD_DIRECTORIES = [
|
||||
'archive\\pc\\mod',
|
||||
'r6\\scripts',
|
||||
'r6\\tweaks',
|
||||
'red4ext\\plugins',
|
||||
'bin\\x64\\plugins\\cyber_engine_tweaks\\mods',
|
||||
'mods',
|
||||
];
|
||||
|
||||
/** Environment variable that overrides discovery with a Linux path to the game. */
|
||||
export const GAME_PATH_OVERRIDE_VAR = 'CYBERPUNK2077_PATH';
|
||||
|
||||
const nodeFileWriter: FileWriter = {
|
||||
makeDir: (path) => {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
},
|
||||
writeFile: (path, contents) => {
|
||||
fs.writeFileSync(path, contents, 'utf8');
|
||||
},
|
||||
};
|
||||
|
||||
function log(level: string, message: string, metadata?: unknown): void {
|
||||
try {
|
||||
vortexLog(level, `[cyberpunk2077-linux] ${message}`, metadata);
|
||||
} catch {
|
||||
// Logging must never break discovery or installation.
|
||||
}
|
||||
}
|
||||
|
||||
/** Locates the game, preferring an explicit override from the environment. */
|
||||
export function findGame(): string | undefined {
|
||||
const gamePath = discoverGame(nodeFileAccess, {
|
||||
overridePath: process.env[GAME_PATH_OVERRIDE_VAR],
|
||||
});
|
||||
|
||||
if (gamePath === undefined) {
|
||||
log('warn', 'Cyberpunk 2077 not found in any native Steam library');
|
||||
} else {
|
||||
log('info', `found Cyberpunk 2077 at ${gamePath}`);
|
||||
}
|
||||
return gamePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates C:\Program Files (x86)\Steam\config\libraryfolders.vdf inside the
|
||||
* prefix. Vortex core reads it during its store scan and a missing file surfaces
|
||||
* as "unrecoverable error: ENOENT ... libraryfolders.vdf".
|
||||
*/
|
||||
export function repairSteamLibraryFolders(): void {
|
||||
try {
|
||||
const libraries = dedupeByCanonicalPath(
|
||||
findSteamRoots(nodeFileAccess).flatMap((root) => findSteamLibraries(nodeFileAccess, root)),
|
||||
(candidate) => fs.realpathSync(candidate),
|
||||
);
|
||||
const result = ensureSteamShim(nodeFileAccess, nodeFileWriter, libraries);
|
||||
log('info', `steam libraryfolders.vdf shim ${result.action}: ${result.path}`);
|
||||
} catch (err) {
|
||||
log('warn', 'could not write the steam libraryfolders.vdf shim', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the directories mods deploy into. */
|
||||
export function prepareForModding(gamePath: string): void {
|
||||
for (const directory of MOD_DIRECTORIES) {
|
||||
fs.mkdirSync(joinWin(gamePath, directory), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function testSupported(_files: string[], gameId: string): Promise<SupportedResult> {
|
||||
return Promise.resolve({ supported: gameId === GAME_ID, requiredFiles: [] });
|
||||
}
|
||||
|
||||
function install(files: string[], destinationPath: string): Promise<InstallResult> {
|
||||
const modName = modNameFromStagingPath(destinationPath);
|
||||
const instructions = buildInstructions(files, modName);
|
||||
log('info', `installing "${modName}": ${instructions.length} file(s)`);
|
||||
return Promise.resolve({ instructions });
|
||||
}
|
||||
|
||||
function main(context: ExtensionContext): boolean {
|
||||
repairSteamLibraryFolders();
|
||||
|
||||
context.registerGame({
|
||||
id: GAME_ID,
|
||||
name: 'Cyberpunk 2077 (Linux/Proton)',
|
||||
mergeMods: true,
|
||||
logo: 'gameart.png',
|
||||
queryPath: findGame,
|
||||
queryModPath: () => '',
|
||||
executable: () => GAME_EXE,
|
||||
requiredFiles: [GAME_EXE],
|
||||
environment: { SteamAPPId: CYBERPUNK_STEAM_APP_ID },
|
||||
details: {
|
||||
steamAppId: Number(CYBERPUNK_STEAM_APP_ID),
|
||||
nexusPageId: 'cyberpunk2077',
|
||||
},
|
||||
setup: (discovery) => {
|
||||
if (discovery.path !== undefined) {
|
||||
prepareForModding(discovery.path);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
context.registerInstaller('cyberpunk2077linux-layout', 25, testSupported, install);
|
||||
|
||||
context.once(() => {
|
||||
repairSteamLibraryFolders();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main;
|
||||
@@ -0,0 +1,171 @@
|
||||
// About me: Tests for turning the file list of a downloaded mod archive into
|
||||
// install instructions rooted at the Cyberpunk 2077 game directory. Cyberpunk
|
||||
// mods ship in several shapes (game-root trees, bare .archive files, CET Lua
|
||||
// mods, redscript, REDmod packages), often wrapped in one or more junk folders.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildInstructions, detectLayout, modNameFromStagingPath } from './installer.ts';
|
||||
|
||||
describe('modNameFromStagingPath', () => {
|
||||
it('takes the folder name Vortex is installing into', () => {
|
||||
expect(modNameFromStagingPath('Z:\\mnt\\games\\vortex-staging\\Cool Mod-123-1-2')).toBe(
|
||||
'Cool Mod-123-1-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips the .installing suffix Vortex adds while unpacking', () => {
|
||||
expect(modNameFromStagingPath('Z:\\staging\\Cool Mod-123.installing')).toBe('Cool Mod-123');
|
||||
});
|
||||
|
||||
it('handles forward slashes and a trailing separator', () => {
|
||||
expect(modNameFromStagingPath('/tmp/staging/Cool Mod/')).toBe('Cool Mod');
|
||||
});
|
||||
|
||||
it('falls back to a generic name for an empty path', () => {
|
||||
expect(modNameFromStagingPath('')).toBe('mod');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLayout', () => {
|
||||
it('detects a game-root tree', () => {
|
||||
expect(detectLayout(['archive\\pc\\mod\\cool.archive'])).toEqual({
|
||||
kind: 'gameRoot',
|
||||
prefix: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a game-root tree wrapped in junk folders', () => {
|
||||
const layout = detectLayout([
|
||||
'Cool Mod v1.2\\readme.txt',
|
||||
'Cool Mod v1.2\\archive\\pc\\mod\\cool.archive',
|
||||
'Cool Mod v1.2\\r6\\scripts\\cool.reds',
|
||||
]);
|
||||
|
||||
expect(layout).toEqual({ kind: 'gameRoot', prefix: 'Cool Mod v1.2' });
|
||||
});
|
||||
|
||||
it('detects bare archive files', () => {
|
||||
expect(detectLayout(['cool.archive', 'cool.archive.xl'])).toEqual({
|
||||
kind: 'archiveOnly',
|
||||
prefix: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a CET mod by its init.lua', () => {
|
||||
expect(detectLayout(['MyCet\\init.lua', 'MyCet\\modules\\ui.lua'])).toEqual({
|
||||
kind: 'cet',
|
||||
prefix: 'MyCet',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects bare redscript files', () => {
|
||||
expect(detectLayout(['tweaks.reds'])).toEqual({ kind: 'redscript', prefix: '' });
|
||||
});
|
||||
|
||||
it('detects a REDmod package by info.json plus archives', () => {
|
||||
expect(detectLayout(['MyRedmod\\info.json', 'MyRedmod\\archives\\mod.archive'])).toEqual({
|
||||
kind: 'redmod',
|
||||
prefix: 'MyRedmod',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a bare red4ext plugin dll', () => {
|
||||
expect(detectLayout(['plugin.dll'])).toEqual({ kind: 'red4extPlugin', prefix: '' });
|
||||
});
|
||||
|
||||
it('reports an unknown layout when nothing matches', () => {
|
||||
expect(detectLayout(['readme.txt', 'screenshot.png'])).toEqual({
|
||||
kind: 'unknown',
|
||||
prefix: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores directory entries and mixed separators', () => {
|
||||
expect(detectLayout(['Cool Mod/', 'Cool Mod/archive/pc/mod/cool.archive'])).toEqual({
|
||||
kind: 'gameRoot',
|
||||
prefix: 'Cool Mod',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInstructions', () => {
|
||||
it('strips the wrapper folder from a game-root tree', () => {
|
||||
const instructions = buildInstructions(
|
||||
['Cool Mod v1.2\\archive\\pc\\mod\\cool.archive', 'Cool Mod v1.2\\r6\\scripts\\cool.reds'],
|
||||
'Cool Mod',
|
||||
);
|
||||
|
||||
expect(instructions).toEqual([
|
||||
{
|
||||
type: 'copy',
|
||||
source: 'Cool Mod v1.2\\archive\\pc\\mod\\cool.archive',
|
||||
destination: 'archive\\pc\\mod\\cool.archive',
|
||||
},
|
||||
{
|
||||
type: 'copy',
|
||||
source: 'Cool Mod v1.2\\r6\\scripts\\cool.reds',
|
||||
destination: 'r6\\scripts\\cool.reds',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps non-game files out of the install when they sit beside the game root', () => {
|
||||
const instructions = buildInstructions(
|
||||
['Cool Mod\\readme.txt', 'Cool Mod\\archive\\pc\\mod\\cool.archive'],
|
||||
'Cool Mod',
|
||||
);
|
||||
|
||||
expect(instructions.map((i) => i.destination)).toEqual(['archive\\pc\\mod\\cool.archive']);
|
||||
});
|
||||
|
||||
it('routes bare archive files into archive\\pc\\mod', () => {
|
||||
expect(buildInstructions(['cool.archive'], 'Cool Mod')).toEqual([
|
||||
{ type: 'copy', source: 'cool.archive', destination: 'archive\\pc\\mod\\cool.archive' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes a CET mod under its own folder in the CET mods directory', () => {
|
||||
const instructions = buildInstructions(['MyCet\\init.lua', 'MyCet\\modules\\ui.lua'], 'My CET');
|
||||
|
||||
expect(instructions.map((i) => i.destination)).toEqual([
|
||||
'bin\\x64\\plugins\\cyber_engine_tweaks\\mods\\MyCet\\init.lua',
|
||||
'bin\\x64\\plugins\\cyber_engine_tweaks\\mods\\MyCet\\modules\\ui.lua',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes bare redscript files into r6\\scripts under the mod name', () => {
|
||||
expect(buildInstructions(['tweaks.reds'], 'My Mod')).toEqual([
|
||||
{ type: 'copy', source: 'tweaks.reds', destination: 'r6\\scripts\\My Mod\\tweaks.reds' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes a REDmod package under mods\\<name>', () => {
|
||||
const instructions = buildInstructions(
|
||||
['MyRedmod\\info.json', 'MyRedmod\\archives\\mod.archive'],
|
||||
'My Redmod',
|
||||
);
|
||||
|
||||
expect(instructions.map((i) => i.destination)).toEqual([
|
||||
'mods\\MyRedmod\\info.json',
|
||||
'mods\\MyRedmod\\archives\\mod.archive',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes a bare dll into red4ext\\plugins under the mod name', () => {
|
||||
expect(buildInstructions(['plugin.dll'], 'My Plugin')).toEqual([
|
||||
{ type: 'copy', source: 'plugin.dll', destination: 'red4ext\\plugins\\My Plugin\\plugin.dll' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies an unknown layout verbatim so nothing is silently dropped', () => {
|
||||
expect(buildInstructions(['readme.txt'], 'My Mod')).toEqual([
|
||||
{ type: 'copy', source: 'readme.txt', destination: 'readme.txt' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops directory entries', () => {
|
||||
const instructions = buildInstructions(['Cool Mod\\', 'Cool Mod\\archive\\pc\\mod\\a.archive'], 'Cool Mod');
|
||||
|
||||
expect(instructions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
// About me: Works out where the files of a Cyberpunk 2077 mod archive belong
|
||||
// inside the game directory. Mod authors package their work in several shapes and
|
||||
// usually wrap it in a versioned folder, so a plain "copy everything" install
|
||||
// puts files in the wrong place. This module maps an archive's file list to
|
||||
// install instructions relative to the game root; it is pure so it can be tested
|
||||
// without Vortex.
|
||||
|
||||
/** Top-level directories of the game that a mod archive may mirror. */
|
||||
export const GAME_ROOT_DIRS = ['archive', 'bin', 'engine', 'r6', 'red4ext', 'mods', 'plugins'];
|
||||
|
||||
/** Destination for loose .archive files. */
|
||||
export const ARCHIVE_MOD_DIR = 'archive\\pc\\mod';
|
||||
|
||||
/** Destination folder that holds one directory per Cyber Engine Tweaks mod. */
|
||||
export const CET_MODS_DIR = 'bin\\x64\\plugins\\cyber_engine_tweaks\\mods';
|
||||
|
||||
/** Destination for redscript source files. */
|
||||
export const REDSCRIPT_DIR = 'r6\\scripts';
|
||||
|
||||
/** Destination that holds one directory per RED4ext plugin. */
|
||||
export const RED4EXT_PLUGINS_DIR = 'red4ext\\plugins';
|
||||
|
||||
/** Destination that holds one directory per REDmod package. */
|
||||
export const REDMOD_DIR = 'mods';
|
||||
|
||||
export type LayoutKind =
|
||||
| 'gameRoot'
|
||||
| 'redmod'
|
||||
| 'cet'
|
||||
| 'archiveOnly'
|
||||
| 'redscript'
|
||||
| 'red4extPlugin'
|
||||
| 'unknown';
|
||||
|
||||
export interface Layout {
|
||||
kind: LayoutKind;
|
||||
/** Wrapper directory to strip, '' when the payload sits at the archive root. */
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface Instruction {
|
||||
type: 'copy';
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
function normalise(entry: string): string {
|
||||
return entry.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
/** Keeps real files only: Vortex lists directories too, with a trailing separator. */
|
||||
function fileEntries(entries: string[]): string[] {
|
||||
return entries.map(normalise).filter((entry) => entry !== '' && !entry.endsWith('\\'));
|
||||
}
|
||||
|
||||
function segmentsOf(entry: string): string[] {
|
||||
return entry.split('\\').filter((segment) => segment !== '');
|
||||
}
|
||||
|
||||
function dirOf(entry: string): string {
|
||||
const segments = segmentsOf(entry);
|
||||
return segments.slice(0, -1).join('\\');
|
||||
}
|
||||
|
||||
function commonPrefix(files: string[]): string {
|
||||
if (files.length === 0) {
|
||||
return '';
|
||||
}
|
||||
let prefix = segmentsOf(dirOf(files[0]));
|
||||
for (const file of files.slice(1)) {
|
||||
const segments = segmentsOf(dirOf(file));
|
||||
let i = 0;
|
||||
while (i < prefix.length && i < segments.length && prefix[i] === segments[i]) {
|
||||
i += 1;
|
||||
}
|
||||
prefix = prefix.slice(0, i);
|
||||
}
|
||||
return prefix.join('\\');
|
||||
}
|
||||
|
||||
function relativeTo(entry: string, prefix: string): string {
|
||||
if (prefix === '') {
|
||||
return entry;
|
||||
}
|
||||
return entry.slice(prefix.length + 1);
|
||||
}
|
||||
|
||||
function hasExtension(files: string[], extensions: string[]): boolean {
|
||||
return files.some((file) => extensions.some((ext) => file.toLowerCase().endsWith(ext)));
|
||||
}
|
||||
|
||||
function everyFileHasExtension(files: string[], extensions: string[]): boolean {
|
||||
return (
|
||||
files.length > 0 &&
|
||||
files.every((file) => extensions.some((ext) => file.toLowerCase().endsWith(ext)))
|
||||
);
|
||||
}
|
||||
|
||||
/** Finds the wrapper prefix of an archive that mirrors the game directory layout. */
|
||||
function findGameRootPrefix(files: string[]): string | undefined {
|
||||
let best: { depth: number; prefix: string } | undefined;
|
||||
|
||||
for (const file of files) {
|
||||
const segments = segmentsOf(file);
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if (!GAME_ROOT_DIRS.includes(segments[i].toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
if (best === undefined || i < best.depth) {
|
||||
best = { depth: i, prefix: segments.slice(0, i).join('\\') };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return best?.prefix;
|
||||
}
|
||||
|
||||
/** Classifies a mod archive by its file list. */
|
||||
export function detectLayout(entries: string[]): Layout {
|
||||
const files = fileEntries(entries);
|
||||
if (files.length === 0) {
|
||||
return { kind: 'unknown', prefix: '' };
|
||||
}
|
||||
|
||||
const gameRootPrefix = findGameRootPrefix(files);
|
||||
if (gameRootPrefix !== undefined) {
|
||||
return { kind: 'gameRoot', prefix: gameRootPrefix };
|
||||
}
|
||||
|
||||
const prefix = commonPrefix(files);
|
||||
const relative = files.map((file) => relativeTo(file, prefix));
|
||||
|
||||
const isRedmod =
|
||||
relative.some((file) => file.toLowerCase() === 'info.json') &&
|
||||
relative.some((file) => file.toLowerCase().startsWith('archives\\'));
|
||||
if (isRedmod) {
|
||||
return { kind: 'redmod', prefix };
|
||||
}
|
||||
|
||||
if (relative.some((file) => file.toLowerCase() === 'init.lua')) {
|
||||
return { kind: 'cet', prefix };
|
||||
}
|
||||
|
||||
if (everyFileHasExtension(relative, ['.archive', '.archive.xl', '.xl'])) {
|
||||
return { kind: 'archiveOnly', prefix };
|
||||
}
|
||||
|
||||
if (everyFileHasExtension(relative, ['.reds'])) {
|
||||
return { kind: 'redscript', prefix };
|
||||
}
|
||||
|
||||
if (everyFileHasExtension(relative, ['.dll']) && hasExtension(relative, ['.dll'])) {
|
||||
return { kind: 'red4extPlugin', prefix };
|
||||
}
|
||||
|
||||
return { kind: 'unknown', prefix: '' };
|
||||
}
|
||||
|
||||
/** Derives a mod name from the staging directory Vortex unpacks an archive into. */
|
||||
export function modNameFromStagingPath(stagingPath: string): string {
|
||||
const segments = segmentsOf(normalise(stagingPath));
|
||||
const name = segments[segments.length - 1] ?? '';
|
||||
const withoutMarker = name.replace(/\.installing$/i, '');
|
||||
return withoutMarker === '' ? 'mod' : withoutMarker;
|
||||
}
|
||||
|
||||
/** Replaces characters Windows forbids in a directory name. */
|
||||
export function sanitiseFolderName(name: string): string {
|
||||
return name.replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||
}
|
||||
|
||||
function folderNameFor(prefix: string, modName: string): string {
|
||||
const segments = segmentsOf(prefix);
|
||||
return segments.length > 0 ? segments[segments.length - 1] : sanitiseFolderName(modName);
|
||||
}
|
||||
|
||||
/** Maps a mod archive's file list to copy instructions relative to the game root. */
|
||||
export function buildInstructions(entries: string[], modName: string): Instruction[] {
|
||||
const files = fileEntries(entries);
|
||||
const layout = detectLayout(entries);
|
||||
|
||||
const copy = (source: string, destination: string): Instruction => ({
|
||||
type: 'copy',
|
||||
source,
|
||||
destination,
|
||||
});
|
||||
|
||||
switch (layout.kind) {
|
||||
case 'gameRoot': {
|
||||
const inGameRoot = files.filter((file) => {
|
||||
const relative = relativeTo(file, layout.prefix);
|
||||
return (
|
||||
file.startsWith(layout.prefix) &&
|
||||
GAME_ROOT_DIRS.includes(segmentsOf(relative)[0]?.toLowerCase() ?? '')
|
||||
);
|
||||
});
|
||||
return inGameRoot.map((file) => copy(file, relativeTo(file, layout.prefix)));
|
||||
}
|
||||
|
||||
case 'redmod': {
|
||||
const folder = folderNameFor(layout.prefix, modName);
|
||||
return files.map((file) =>
|
||||
copy(file, `${REDMOD_DIR}\\${folder}\\${relativeTo(file, layout.prefix)}`),
|
||||
);
|
||||
}
|
||||
|
||||
case 'cet': {
|
||||
const folder = folderNameFor(layout.prefix, modName);
|
||||
return files.map((file) =>
|
||||
copy(file, `${CET_MODS_DIR}\\${folder}\\${relativeTo(file, layout.prefix)}`),
|
||||
);
|
||||
}
|
||||
|
||||
case 'archiveOnly':
|
||||
return files.map((file) => copy(file, `${ARCHIVE_MOD_DIR}\\${relativeTo(file, layout.prefix)}`));
|
||||
|
||||
case 'redscript': {
|
||||
const folder = sanitiseFolderName(modName);
|
||||
return files.map((file) =>
|
||||
copy(file, `${REDSCRIPT_DIR}\\${folder}\\${relativeTo(file, layout.prefix)}`),
|
||||
);
|
||||
}
|
||||
|
||||
case 'red4extPlugin': {
|
||||
const folder = sanitiseFolderName(modName);
|
||||
return files.map((file) =>
|
||||
copy(file, `${RED4EXT_PLUGINS_DIR}\\${folder}\\${relativeTo(file, layout.prefix)}`),
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return files.map((file) => copy(file, file));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// About me: Tests for the libraryfolders.vdf shim that gets written into the Wine
|
||||
// prefix. Vortex core (not just the game extension) reads
|
||||
// C:\Program Files (x86)\Steam\config\libraryfolders.vdf and dies with an
|
||||
// unhandled ENOENT when it is missing, so the extension recreates it pointing at
|
||||
// the real Linux libraries via Z:.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FileAccess } from './fileAccess.ts';
|
||||
import {
|
||||
PREFIX_STEAM_CONFIG_DIR,
|
||||
buildShimVdf,
|
||||
dedupeByCanonicalPath,
|
||||
ensureSteamShim,
|
||||
} from './steamShim.ts';
|
||||
import { parseVdf } from './vdf.ts';
|
||||
|
||||
function fakeFs(files: Record<string, string>): FileAccess {
|
||||
const keys = () => Object.keys(files);
|
||||
return {
|
||||
exists: (p) => keys().some((k) => k === p || k.startsWith(`${p}\\`)),
|
||||
readFile: (p) => {
|
||||
if (files[p] === undefined) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
|
||||
}
|
||||
return files[p];
|
||||
},
|
||||
readDir: (p) => {
|
||||
const prefix = `${p}\\`;
|
||||
return [
|
||||
...new Set(
|
||||
keys()
|
||||
.filter((k) => k.startsWith(prefix))
|
||||
.map((k) => k.slice(prefix.length).split('\\')[0]),
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function recordingWriter(files: Record<string, string>) {
|
||||
const created: string[] = [];
|
||||
return {
|
||||
created,
|
||||
writer: {
|
||||
makeDir: (p: string) => {
|
||||
created.push(p);
|
||||
},
|
||||
writeFile: (p: string, contents: string) => {
|
||||
files[p] = contents;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildShimVdf', () => {
|
||||
it('writes each library as a numbered entry with a Wine path', () => {
|
||||
const text = buildShimVdf(['Z:\\home\\tester\\.steam\\debian-installation', 'Z:\\mnt\\games\\SteamLibrary']);
|
||||
|
||||
expect(parseVdf(text)).toEqual({
|
||||
libraryfolders: {
|
||||
'0': { path: 'Z:\\home\\tester\\.steam\\debian-installation', label: '', apps: {} },
|
||||
'1': { path: 'Z:\\mnt\\games\\SteamLibrary', label: '', apps: {} },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Linux paths and converts them', () => {
|
||||
const parsed = parseVdf(buildShimVdf(['/mnt/games/SteamLibrary'])).libraryfolders;
|
||||
|
||||
expect(parsed).toEqual({ '0': { path: 'Z:\\mnt\\games\\SteamLibrary', label: '', apps: {} } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeByCanonicalPath', () => {
|
||||
// ~/.steam/steam, ~/.steam/root and ~/.steam/debian-installation are usually
|
||||
// symlinks to one directory; listing all three makes Vortex scan it repeatedly.
|
||||
const canonical = (p: string) =>
|
||||
p === 'Z:\\home\\t\\.steam\\steam' || p === 'Z:\\home\\t\\.steam\\root'
|
||||
? 'Z:\\home\\t\\.steam\\debian-installation'
|
||||
: p;
|
||||
|
||||
it('collapses paths that resolve to the same directory, keeping the first', () => {
|
||||
const libraries = [
|
||||
'Z:\\home\\t\\.steam\\steam',
|
||||
'Z:\\mnt\\games\\SteamLibrary',
|
||||
'Z:\\home\\t\\.steam\\root',
|
||||
'Z:\\home\\t\\.steam\\debian-installation',
|
||||
];
|
||||
|
||||
expect(dedupeByCanonicalPath(libraries, canonical)).toEqual([
|
||||
'Z:\\home\\t\\.steam\\steam',
|
||||
'Z:\\mnt\\games\\SteamLibrary',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps entries whose canonical path cannot be resolved', () => {
|
||||
const failing = (p: string) => {
|
||||
throw new Error(`ENOENT: ${p}`);
|
||||
};
|
||||
|
||||
expect(dedupeByCanonicalPath(['Z:\\a', 'Z:\\b'], failing)).toEqual(['Z:\\a', 'Z:\\b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureSteamShim', () => {
|
||||
const shimPath = `${PREFIX_STEAM_CONFIG_DIR}\\libraryfolders.vdf`;
|
||||
|
||||
it('writes the shim when the file is missing', () => {
|
||||
const files: Record<string, string> = {
|
||||
'C:\\Program Files (x86)\\Steam\\steam.exe': 'MZ',
|
||||
};
|
||||
const { writer, created } = recordingWriter(files);
|
||||
|
||||
const result = ensureSteamShim(fakeFs(files), writer, ['Z:\\mnt\\games\\SteamLibrary']);
|
||||
|
||||
expect(result).toEqual({ action: 'written', path: shimPath });
|
||||
expect(created).toContain(PREFIX_STEAM_CONFIG_DIR);
|
||||
expect(parseVdf(files[shimPath]).libraryfolders).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves an existing shim alone', () => {
|
||||
const files: Record<string, string> = { [shimPath]: '"libraryfolders"\n{\n}\n' };
|
||||
const { writer } = recordingWriter(files);
|
||||
|
||||
expect(ensureSteamShim(fakeFs(files), writer, ['Z:\\mnt\\games\\SteamLibrary'])).toEqual({
|
||||
action: 'exists',
|
||||
path: shimPath,
|
||||
});
|
||||
expect(files[shimPath]).toBe('"libraryfolders"\n{\n}\n');
|
||||
});
|
||||
|
||||
it('replaces an existing shim when overwrite is requested', () => {
|
||||
const files: Record<string, string> = {
|
||||
'C:\\Program Files (x86)\\Steam\\steam.exe': 'MZ',
|
||||
[shimPath]: '"libraryfolders"\n{\n}\n',
|
||||
};
|
||||
const { writer } = recordingWriter(files);
|
||||
|
||||
const result = ensureSteamShim(fakeFs(files), writer, ['Z:\\mnt\\games\\SteamLibrary'], {
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: 'written', path: shimPath });
|
||||
expect(files[shimPath]).toContain('Z:\\\\mnt\\\\games\\\\SteamLibrary');
|
||||
});
|
||||
|
||||
it('does nothing when the prefix has no Steam directory at all', () => {
|
||||
// Outside a Wine prefix "C:\..." is just a filename, and creating it would
|
||||
// litter the working directory with a literal 'C:\Program Files...' folder.
|
||||
const files: Record<string, string> = {};
|
||||
const { writer, created } = recordingWriter(files);
|
||||
|
||||
expect(ensureSteamShim(fakeFs(files), writer, ['Z:\\mnt\\games\\SteamLibrary'])).toEqual({
|
||||
action: 'skipped',
|
||||
path: shimPath,
|
||||
});
|
||||
expect(created).toEqual([]);
|
||||
expect(files[shimPath]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when no Steam libraries were found', () => {
|
||||
const files: Record<string, string> = { 'C:\\Program Files (x86)\\Steam\\steam.exe': 'MZ' };
|
||||
const { writer } = recordingWriter(files);
|
||||
|
||||
expect(ensureSteamShim(fakeFs(files), writer, [])).toEqual({
|
||||
action: 'skipped',
|
||||
path: shimPath,
|
||||
});
|
||||
expect(files[shimPath]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// About me: Recreates the libraryfolders.vdf that Vortex core expects inside the
|
||||
// Wine prefix. SteamTinkerLaunch drops steamclient.dll into
|
||||
// C:\Program Files (x86)\Steam but never writes config\libraryfolders.vdf, and
|
||||
// Vortex's Steam store helper reads that file unconditionally: the unhandled
|
||||
// ENOENT is reported as "unrecoverable error" and kills the renderer. The shim
|
||||
// points Vortex at the real Linux libraries through the Z: drive.
|
||||
import type { FileAccess } from './fileAccess.ts';
|
||||
import { stringifyVdf, type VdfNode } from './vdf.ts';
|
||||
import { joinWin, toWinePath } from './winePath.ts';
|
||||
|
||||
/** Where SteamTinkerLaunch drops the Windows-side Steam client inside the prefix. */
|
||||
export const PREFIX_STEAM_DIR = 'C:\\Program Files (x86)\\Steam';
|
||||
|
||||
/** Where the Windows-side Steam client keeps its config inside the prefix. */
|
||||
export const PREFIX_STEAM_CONFIG_DIR = `${PREFIX_STEAM_DIR}\\config`;
|
||||
|
||||
/** Filesystem writes the shim needs; separate from FileAccess so tests stay read-only by default. */
|
||||
export interface FileWriter {
|
||||
makeDir(path: string): void;
|
||||
writeFile(path: string, contents: string): void;
|
||||
}
|
||||
|
||||
export interface ShimResult {
|
||||
action: 'written' | 'exists' | 'skipped';
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Renders a libraryfolders.vdf listing the given libraries as Wine paths. */
|
||||
export function buildShimVdf(libraries: string[]): string {
|
||||
const folders: VdfNode = {};
|
||||
|
||||
libraries.forEach((library, index) => {
|
||||
folders[String(index)] = {
|
||||
path: toWinePath(library),
|
||||
label: '',
|
||||
apps: {},
|
||||
};
|
||||
});
|
||||
|
||||
return stringifyVdf({ libraryfolders: folders });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops libraries that point at the same directory through different symlinks
|
||||
* (~/.steam/steam, ~/.steam/root and ~/.steam/debian-installation typically all
|
||||
* resolve to one place), so Vortex scans each library exactly once.
|
||||
*/
|
||||
export function dedupeByCanonicalPath(
|
||||
libraries: string[],
|
||||
canonicalise: (path: string) => string,
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const library of libraries) {
|
||||
let key: string;
|
||||
try {
|
||||
key = canonicalise(library);
|
||||
} catch {
|
||||
key = library;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(library);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ShimOptions {
|
||||
/** Rewrite the shim even if a libraryfolders.vdf is already there. */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
/** Writes the shim if it is missing. Never overwrites a file Steam or the user provided, unless asked to. */
|
||||
export function ensureSteamShim(
|
||||
files: FileAccess,
|
||||
writer: FileWriter,
|
||||
libraries: string[],
|
||||
options: ShimOptions = {},
|
||||
): ShimResult {
|
||||
const path = joinWin(PREFIX_STEAM_CONFIG_DIR, 'libraryfolders.vdf');
|
||||
|
||||
if (files.exists(path) && options.overwrite !== true) {
|
||||
return { action: 'exists', path };
|
||||
}
|
||||
// Outside a Wine prefix "C:\..." is an ordinary relative filename, so creating
|
||||
// it would litter the working directory instead of repairing anything.
|
||||
if (!files.exists(PREFIX_STEAM_DIR)) {
|
||||
return { action: 'skipped', path };
|
||||
}
|
||||
if (libraries.length === 0) {
|
||||
return { action: 'skipped', path };
|
||||
}
|
||||
|
||||
writer.makeDir(PREFIX_STEAM_CONFIG_DIR);
|
||||
writer.writeFile(path, buildShimVdf(libraries));
|
||||
return { action: 'written', path };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// About me: Tests for the Valve KeyValues (VDF/ACF) reader and writer.
|
||||
// Covers the exact shapes this extension has to handle: libraryfolders.vdf
|
||||
// with nested library blocks and appmanifest_*.acf app metadata.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseVdf, stringifyVdf } from './vdf.ts';
|
||||
|
||||
describe('parseVdf', () => {
|
||||
it('parses a flat block of key/value pairs', () => {
|
||||
const result = parseVdf('"root"\n{\n\t"path"\t\t"/mnt/games"\n\t"label"\t\t""\n}\n');
|
||||
|
||||
expect(result).toEqual({ root: { path: '/mnt/games', label: '' } });
|
||||
});
|
||||
|
||||
it('parses nested blocks', () => {
|
||||
const text = `"libraryfolders"
|
||||
{
|
||||
\t"0"
|
||||
\t{
|
||||
\t\t"path"\t\t"/home/user/.steam/debian-installation"
|
||||
\t\t"apps"
|
||||
\t\t{
|
||||
\t\t\t"1091500"\t\t"12345"
|
||||
\t\t}
|
||||
\t}
|
||||
}`;
|
||||
|
||||
expect(parseVdf(text)).toEqual({
|
||||
libraryfolders: {
|
||||
'0': {
|
||||
path: '/home/user/.steam/debian-installation',
|
||||
apps: { '1091500': '12345' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores // comments and blank lines', () => {
|
||||
const text = '// leading comment\n"root"\n{\n\n\t"a"\t"1" // trailing\n}\n';
|
||||
|
||||
expect(parseVdf(text)).toEqual({ root: { a: '1' } });
|
||||
});
|
||||
|
||||
it('keeps backslashes in Windows paths and unescapes quotes', () => {
|
||||
const text = '"root"\n{\n\t"path"\t\t"Z:\\\\mnt\\\\games\\\\SteamLibrary"\n\t"q"\t"say \\"hi\\""\n}\n';
|
||||
|
||||
expect(parseVdf(text)).toEqual({
|
||||
root: { path: 'Z:\\mnt\\games\\SteamLibrary', q: 'say "hi"' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an appmanifest .acf', () => {
|
||||
const acf = `"AppState"
|
||||
{
|
||||
\t"appid"\t\t"1091500"
|
||||
\t"name"\t\t"Cyberpunk 2077"
|
||||
\t"installdir"\t\t"Cyberpunk 2077"
|
||||
}`;
|
||||
|
||||
expect(parseVdf(acf)).toEqual({
|
||||
AppState: { appid: '1091500', name: 'Cyberpunk 2077', installdir: 'Cyberpunk 2077' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on an unterminated block', () => {
|
||||
expect(() => parseVdf('"root"\n{\n\t"a"\t"1"\n')).toThrow(/unterminated/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringifyVdf', () => {
|
||||
it('round-trips through parseVdf', () => {
|
||||
const value = {
|
||||
libraryfolders: {
|
||||
'0': { path: 'C:\\Program Files (x86)\\Steam', apps: { '1091500': '0' } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(parseVdf(stringifyVdf(value))).toEqual(value);
|
||||
});
|
||||
|
||||
it('writes tab-indented Valve formatting', () => {
|
||||
expect(stringifyVdf({ root: { a: '1' } })).toBe('"root"\n{\n\t"a"\t\t"1"\n}\n');
|
||||
});
|
||||
|
||||
it('escapes backslashes so Windows paths survive a reparse', () => {
|
||||
const text = stringifyVdf({ root: { path: 'Z:\\home\\user' } });
|
||||
|
||||
expect(text).toContain('"path"\t\t"Z:\\\\home\\\\user"');
|
||||
});
|
||||
});
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// About me: Minimal reader/writer for Valve's KeyValues text format (VDF/ACF).
|
||||
// The extension needs it to read Steam's libraryfolders.vdf and appmanifest_*.acf
|
||||
// from the native Linux Steam install, and to write the libraryfolders.vdf shim
|
||||
// into the Wine prefix. Deliberately dependency-free so the bundled Vortex
|
||||
// extension stays a single self-contained file.
|
||||
|
||||
/** A parsed KeyValues tree: leaves are strings, branches are nested objects. */
|
||||
export interface VdfNode {
|
||||
[key: string]: string | VdfNode;
|
||||
}
|
||||
|
||||
interface Token {
|
||||
kind: 'string' | 'open' | 'close';
|
||||
value: string;
|
||||
}
|
||||
|
||||
function tokenize(text: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
|
||||
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
|
||||
i += 1;
|
||||
} else if (ch === '/' && text[i + 1] === '/') {
|
||||
while (i < text.length && text[i] !== '\n') {
|
||||
i += 1;
|
||||
}
|
||||
} else if (ch === '{') {
|
||||
tokens.push({ kind: 'open', value: '{' });
|
||||
i += 1;
|
||||
} else if (ch === '}') {
|
||||
tokens.push({ kind: 'close', value: '}' });
|
||||
i += 1;
|
||||
} else if (ch === '"') {
|
||||
i += 1;
|
||||
let value = '';
|
||||
while (i < text.length && text[i] !== '"') {
|
||||
if (text[i] === '\\') {
|
||||
value += unescapeChar(text[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
value += text[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (i >= text.length) {
|
||||
throw new Error('VDF parse error: unterminated string literal');
|
||||
}
|
||||
i += 1;
|
||||
tokens.push({ kind: 'string', value });
|
||||
} else {
|
||||
// Unquoted token, terminated by whitespace or a brace.
|
||||
let value = '';
|
||||
while (i < text.length && !' \t\r\n{}'.includes(text[i])) {
|
||||
value += text[i];
|
||||
i += 1;
|
||||
}
|
||||
tokens.push({ kind: 'string', value });
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function unescapeChar(ch: string | undefined): string {
|
||||
switch (ch) {
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 't':
|
||||
return '\t';
|
||||
case '\\':
|
||||
return '\\';
|
||||
case '"':
|
||||
return '"';
|
||||
default:
|
||||
return ch ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeValue(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Parses KeyValues text into a nested object. Throws on malformed input. */
|
||||
export function parseVdf(text: string): VdfNode {
|
||||
const tokens = tokenize(text);
|
||||
let pos = 0;
|
||||
|
||||
function parseBlock(depth: number): VdfNode {
|
||||
const node: VdfNode = {};
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const token = tokens[pos];
|
||||
|
||||
if (token.kind === 'close') {
|
||||
if (depth === 0) {
|
||||
throw new Error('VDF parse error: unexpected "}" at top level');
|
||||
}
|
||||
pos += 1;
|
||||
return node;
|
||||
}
|
||||
|
||||
if (token.kind !== 'string') {
|
||||
throw new Error(`VDF parse error: expected key, got "${token.value}"`);
|
||||
}
|
||||
pos += 1;
|
||||
|
||||
const next = tokens[pos];
|
||||
if (next === undefined) {
|
||||
throw new Error(`VDF parse error: key "${token.value}" has no value`);
|
||||
}
|
||||
|
||||
if (next.kind === 'open') {
|
||||
pos += 1;
|
||||
node[token.value] = parseBlock(depth + 1);
|
||||
} else if (next.kind === 'string') {
|
||||
pos += 1;
|
||||
node[token.value] = next.value;
|
||||
} else {
|
||||
throw new Error(`VDF parse error: key "${token.value}" followed by "}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > 0) {
|
||||
throw new Error('VDF parse error: unterminated block, missing "}"');
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
return parseBlock(0);
|
||||
}
|
||||
|
||||
/** Serialises a KeyValues tree back to Valve's tab-indented text format. */
|
||||
export function stringifyVdf(node: VdfNode, indent = 0): string {
|
||||
const pad = '\t'.repeat(indent);
|
||||
let out = '';
|
||||
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (typeof value === 'string') {
|
||||
out += `${pad}"${escapeValue(key)}"\t\t"${escapeValue(value)}"\n`;
|
||||
} else {
|
||||
out += `${pad}"${escapeValue(key)}"\n${pad}{\n`;
|
||||
out += stringifyVdf(value, indent + 1);
|
||||
out += `${pad}}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
// About me: Ambient declaration for the `vortex-api` module Vortex injects at
|
||||
// runtime. The bundler keeps it external, so only the pieces this extension
|
||||
// calls need a type.
|
||||
declare module 'vortex-api' {
|
||||
export function log(level: string, message: string, metadata?: unknown): void;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// About me: Minimal type declarations for the slice of the Vortex extension API
|
||||
// this extension uses. Vortex injects the real `vortex-api` module at runtime and
|
||||
// the bundler keeps it external, so depending on the full published typings is
|
||||
// unnecessary weight for six call signatures.
|
||||
|
||||
export interface DiscoveryResult {
|
||||
path?: string;
|
||||
store?: string;
|
||||
}
|
||||
|
||||
export interface InstallInstruction {
|
||||
type: 'copy';
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
instructions: InstallInstruction[];
|
||||
}
|
||||
|
||||
export interface SupportedResult {
|
||||
supported: boolean;
|
||||
requiredFiles: string[];
|
||||
}
|
||||
|
||||
export interface GameRegistration {
|
||||
id: string;
|
||||
name: string;
|
||||
mergeMods: boolean;
|
||||
logo?: string;
|
||||
queryPath: () => string | undefined | Promise<string | undefined>;
|
||||
queryModPath: (gamePath: string) => string;
|
||||
executable: (gamePath?: string) => string;
|
||||
requiredFiles: string[];
|
||||
environment?: Record<string, string>;
|
||||
details?: Record<string, unknown>;
|
||||
setup?: (discovery: DiscoveryResult) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ExtensionContext {
|
||||
registerGame: (game: GameRegistration) => void;
|
||||
registerInstaller: (
|
||||
id: string,
|
||||
priority: number,
|
||||
testSupported: (files: string[], gameId: string) => Promise<SupportedResult>,
|
||||
install: (
|
||||
files: string[],
|
||||
destinationPath: string,
|
||||
gameId: string,
|
||||
) => Promise<InstallResult>,
|
||||
) => void;
|
||||
once: (callback: () => void) => void;
|
||||
api: unknown;
|
||||
}
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
@@ -0,0 +1,65 @@
|
||||
// About me: Tests for translating between native Linux paths and the Wine
|
||||
// Z: drive paths that Vortex sees when it runs inside a Proton prefix.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { joinPath, joinWin, toLinuxPath, toWinePath } from './winePath.ts';
|
||||
|
||||
describe('joinPath', () => {
|
||||
it('uses backslashes for a Windows/Wine base', () => {
|
||||
expect(joinPath('Z:\\home\\user', '.steam', 'steam')).toBe('Z:\\home\\user\\.steam\\steam');
|
||||
});
|
||||
|
||||
it('uses forward slashes for a native Linux base', () => {
|
||||
expect(joinPath('/home/user', '.steam\\steam')).toBe('/home/user/.steam/steam');
|
||||
});
|
||||
|
||||
it('keeps the root slash when joining onto /', () => {
|
||||
expect(joinPath('/', 'home')).toBe('/home');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toWinePath', () => {
|
||||
it('maps an absolute Linux path onto the Z: drive', () => {
|
||||
expect(toWinePath('/mnt/games/SteamLibrary')).toBe('Z:\\mnt\\games\\SteamLibrary');
|
||||
});
|
||||
|
||||
it('maps the filesystem root', () => {
|
||||
expect(toWinePath('/')).toBe('Z:\\');
|
||||
});
|
||||
|
||||
it('drops a trailing slash', () => {
|
||||
expect(toWinePath('/home/user/')).toBe('Z:\\home\\user');
|
||||
});
|
||||
|
||||
it('passes through a path that is already a Windows path', () => {
|
||||
expect(toWinePath('C:\\Program Files (x86)\\Steam')).toBe('C:\\Program Files (x86)\\Steam');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLinuxPath', () => {
|
||||
it('maps a Z: drive path back to a Linux path', () => {
|
||||
expect(toLinuxPath('Z:\\mnt\\games\\SteamLibrary')).toBe('/mnt/games/SteamLibrary');
|
||||
});
|
||||
|
||||
it('accepts a lowercase drive letter and forward slashes', () => {
|
||||
expect(toLinuxPath('z:/home/user')).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('returns undefined for a path on another drive', () => {
|
||||
expect(toLinuxPath('C:\\Program Files (x86)\\Steam')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinWin', () => {
|
||||
it('joins segments with backslashes', () => {
|
||||
expect(joinWin('Z:\\mnt\\lib', 'steamapps', 'common')).toBe('Z:\\mnt\\lib\\steamapps\\common');
|
||||
});
|
||||
|
||||
it('normalises forward slashes in segments', () => {
|
||||
expect(joinWin('Z:\\mnt', 'bin/x64/Cyberpunk2077.exe')).toBe('Z:\\mnt\\bin\\x64\\Cyberpunk2077.exe');
|
||||
});
|
||||
|
||||
it('does not double up separators', () => {
|
||||
expect(joinWin('Z:\\mnt\\', '\\steamapps')).toBe('Z:\\mnt\\steamapps');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// About me: Translation between native Linux paths and the Wine paths Vortex
|
||||
// sees from inside a Proton prefix. Wine maps the Linux root at Z:\, so every
|
||||
// real Steam library on this machine is reachable as Z:\mnt\... or Z:\home\...
|
||||
// while Vortex itself believes it is running on Windows.
|
||||
|
||||
/** Wine's default mapping of the Linux filesystem root. */
|
||||
export const LINUX_ROOT_DRIVE = 'Z:';
|
||||
|
||||
/** Converts an absolute Linux path to its Wine equivalent. Windows paths pass through. */
|
||||
export function toWinePath(linuxPath: string): string {
|
||||
if (/^[a-zA-Z]:/.test(linuxPath)) {
|
||||
return linuxPath;
|
||||
}
|
||||
const trimmed = linuxPath.length > 1 ? linuxPath.replace(/\/+$/, '') : linuxPath;
|
||||
return `${LINUX_ROOT_DRIVE}${trimmed.replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
/** Converts a Wine Z: path back to a Linux path, or undefined for other drives. */
|
||||
export function toLinuxPath(winePath: string): string | undefined {
|
||||
const match = /^[zZ]:[\\/](.*)$/.exec(winePath);
|
||||
if (match === null) {
|
||||
return undefined;
|
||||
}
|
||||
return `/${match[1].replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins path segments using the separator that matches the base: backslashes for
|
||||
* a drive-letter (Wine/Windows) base, forward slashes for a native Linux base.
|
||||
*/
|
||||
export function joinPath(base: string, ...segments: string[]): string {
|
||||
if (/^[a-zA-Z]:/.test(base)) {
|
||||
return joinWin(base, ...segments);
|
||||
}
|
||||
return segments.reduce((acc, segment) => {
|
||||
const left = acc.replace(/\/+$/, '');
|
||||
const right = segment.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
return `${left}/${right}`;
|
||||
}, base);
|
||||
}
|
||||
|
||||
/** Joins path segments with backslashes, collapsing duplicate separators. */
|
||||
export function joinWin(base: string, ...segments: string[]): string {
|
||||
return segments.reduce((acc, segment) => {
|
||||
const left = acc.replace(/[\\/]+$/, '');
|
||||
const right = segment.replace(/\//g, '\\').replace(/^\\+/, '');
|
||||
return `${left}\\${right}`;
|
||||
}, base.replace(/\//g, '\\'));
|
||||
}
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# About me: Installs the built extension into the Vortex Wine prefix and repairs
|
||||
# the prefix's missing Steam libraryfolders.vdf. Vortex loads user extensions from
|
||||
# AppData/Roaming/Vortex/plugins inside the prefix, so installing is a copy plus
|
||||
# a restart of Vortex.
|
||||
#
|
||||
# Usage: tools/install-extension.sh [--prefix <pfx>] [--force-shim] [--disable-bundled]
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PREFIX="${VORTEX_PREFIX:-$HOME/.config/steamtinkerlaunch/vortex/compatdata/pfx}"
|
||||
EXTENSION_NAME="game-cyberpunk2077-linux"
|
||||
FORCE_SHIM=""
|
||||
DISABLE_BUNDLED="no"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--prefix) PREFIX="$2"; shift 2 ;;
|
||||
--force-shim) FORCE_SHIM="--force"; shift ;;
|
||||
--disable-bundled) DISABLE_BUNDLED="yes"; shift ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$PREFIX" ]]; then
|
||||
echo "prefix not found: $PREFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PLUGINS_DIR="$PREFIX/drive_c/users/steamuser/AppData/Roaming/Vortex/plugins"
|
||||
if [[ ! -d "$PLUGINS_DIR" ]]; then
|
||||
echo "Vortex plugins directory not found: $PLUGINS_DIR" >&2
|
||||
echo "start Vortex once in this prefix, then run this script again" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> building"
|
||||
(cd "$PROJECT_ROOT" && npm run --silent build)
|
||||
|
||||
TARGET="$PLUGINS_DIR/$EXTENSION_NAME"
|
||||
echo "==> installing to $TARGET"
|
||||
rm -rf "$TARGET"
|
||||
mkdir -p "$TARGET"
|
||||
cp "$PROJECT_ROOT/dist/index.js" "$PROJECT_ROOT/dist/info.json" "$PROJECT_ROOT/dist/package.json" "$TARGET/"
|
||||
cp "$PROJECT_ROOT/dist/gameart.png" "$TARGET/"
|
||||
|
||||
echo "==> repairing the prefix's Steam libraryfolders.vdf"
|
||||
(cd "$PROJECT_ROOT" && node --experimental-strip-types scripts/repair-prefix.mjs --prefix "$PREFIX" ${FORCE_SHIM:+$FORCE_SHIM})
|
||||
|
||||
if [[ "$DISABLE_BUNDLED" == "yes" ]]; then
|
||||
BUNDLED="$PREFIX/drive_c/Program Files/Black Tree Gaming Ltd/Vortex/resources/app.asar.unpacked/bundledPlugins/game-cyberpunk2077"
|
||||
if [[ -d "$BUNDLED" ]]; then
|
||||
echo "==> disabling the bundled Cyberpunk stub: $BUNDLED -> $BUNDLED.disabled"
|
||||
mv "$BUNDLED" "$BUNDLED.disabled"
|
||||
else
|
||||
echo "==> bundled Cyberpunk stub not present, nothing to disable"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> done. Restart Vortex, then manage 'Cyberpunk 2077 (Linux/Proton)'."
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// About me: Vitest configuration for the extension's unit tests.
|
||||
// Tests are pure-logic only (no Wine, no Vortex runtime), so the default
|
||||
// node environment is enough; they live next to the sources as *.test.ts.
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user