This commit is contained in:
2026-08-08 18:00:34 +02:00
commit 41e307b5c1
28 changed files with 4728 additions and 0 deletions
+24
View File
@@ -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/>
+167
View File
@@ -0,0 +1,167 @@
# Cyberpunk 2077 Vortex extension for Linux (Proton / SteamTinkerLaunch)
![Tile artwork](assets/gameart.png)
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

+2081
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+46
View File
@@ -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')}`);
+19
View File
@@ -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)`);
+118
View File
@@ -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);
}
+54
View File
@@ -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;
});
+202
View File
@@ -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();
});
});
+163
View File
@@ -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;
}
+23
View File
@@ -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 [];
}
},
};
+113
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+171
View File
@@ -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);
});
});
+235
View File
@@ -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));
}
}
+171
View 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();
});
});
+101
View File
@@ -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 };
}
+90
View File
@@ -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
View File
@@ -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;
}
+6
View File
@@ -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;
}
+56
View File
@@ -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';
+65
View File
@@ -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');
});
});
+49
View File
@@ -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, '\\'));
}
+60
View File
@@ -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)'."
+15
View File
@@ -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"]
}
+11
View File
@@ -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',
},
});