commit 41e307b5c156d13fbd522e20f55830b62235aff3 Author: Tobias Ganter Date: Sat Aug 8 18:00:34 2026 +0200 init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efb9808 --- /dev/null +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9f013f --- /dev/null +++ b/README.md @@ -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//.steam/steam` and `/mnt//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 +`/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 ` | 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\\` | +| loose `.reds` files | `r6\scripts\\` | +| `info.json` plus `archives/` (REDmod) | `mods\\` | +| a bare `.dll` | `red4ext\plugins\\` | +| 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. diff --git a/assets/gameart.png b/assets/gameart.png new file mode 100644 index 0000000..4fb647c Binary files /dev/null and b/assets/gameart.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..93c11ab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2081 @@ +{ + "name": "game-cyberpunk2077-linux", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "game-cyberpunk2077-linux", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^26.2.0", + "esbuild": "^0.28.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f5a538 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..ba0fd52 --- /dev/null +++ b/scripts/build.mjs @@ -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')}`); diff --git a/scripts/make-gameart.mjs b/scripts/make-gameart.mjs new file mode 100644 index 0000000..754863e --- /dev/null +++ b/scripts/make-gameart.mjs @@ -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)`); diff --git a/scripts/repair-prefix.mjs b/scripts/repair-prefix.mjs new file mode 100644 index 0000000..674e805 --- /dev/null +++ b/scripts/repair-prefix.mjs @@ -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 ] [--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); +} diff --git a/scripts/smoke-load.cjs b/scripts/smoke-load.cjs new file mode 100644 index 0000000..6ae1d7e --- /dev/null +++ b/scripts/smoke-load.cjs @@ -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; + }); diff --git a/src/discovery.test.ts b/src/discovery.test.ts new file mode 100644 index 0000000..501c629 --- /dev/null +++ b/src/discovery.test.ts @@ -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): 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 = { + '/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(); + }); +}); diff --git a/src/discovery.ts b/src/discovery.ts new file mode 100644 index 0000000..55e0e41 --- /dev/null +++ b/src/discovery.ts @@ -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_.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; +} diff --git a/src/fileAccess.ts b/src/fileAccess.ts new file mode 100644 index 0000000..b71a2cf --- /dev/null +++ b/src/fileAccess.ts @@ -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 []; + } + }, +}; diff --git a/src/gameart.test.ts b/src/gameart.test.ts new file mode 100644 index 0000000..b8f319a --- /dev/null +++ b/src/gameart.test.ts @@ -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(); + for (let i = 0; i < data.length; i += 3) { + colours.add(`${data[i]},${data[i + 1]},${data[i + 2]}`); + } + + expect(colours.size).toBeGreaterThan(200); + }); +}); diff --git a/src/gameart.ts b/src/gameart.ts new file mode 100644 index 0000000..04b387d --- /dev/null +++ b/src/gameart.ts @@ -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 = { + 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)), + ]); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1d6489e --- /dev/null +++ b/src/index.ts @@ -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 { + return Promise.resolve({ supported: gameId === GAME_ID, requiredFiles: [] }); +} + +function install(files: string[], destinationPath: string): Promise { + 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; diff --git a/src/installer.test.ts b/src/installer.test.ts new file mode 100644 index 0000000..6e47773 --- /dev/null +++ b/src/installer.test.ts @@ -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\\', () => { + 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); + }); +}); diff --git a/src/installer.ts b/src/installer.ts new file mode 100644 index 0000000..eb35d95 --- /dev/null +++ b/src/installer.ts @@ -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)); + } +} diff --git a/src/steamShim.test.ts b/src/steamShim.test.ts new file mode 100644 index 0000000..9d037e9 --- /dev/null +++ b/src/steamShim.test.ts @@ -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): 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) { + 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 = { + '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 = { [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 = { + '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 = {}; + 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 = { '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(); + }); +}); diff --git a/src/steamShim.ts b/src/steamShim.ts new file mode 100644 index 0000000..eb08af8 --- /dev/null +++ b/src/steamShim.ts @@ -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(); + 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 }; +} diff --git a/src/vdf.test.ts b/src/vdf.test.ts new file mode 100644 index 0000000..7692e55 --- /dev/null +++ b/src/vdf.test.ts @@ -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"'); + }); +}); diff --git a/src/vdf.ts b/src/vdf.ts new file mode 100644 index 0000000..3306b78 --- /dev/null +++ b/src/vdf.ts @@ -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; +} diff --git a/src/vortex-api.d.ts b/src/vortex-api.d.ts new file mode 100644 index 0000000..67cc16a --- /dev/null +++ b/src/vortex-api.d.ts @@ -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; +} diff --git a/src/vortexApi.ts b/src/vortexApi.ts new file mode 100644 index 0000000..bf6ddb1 --- /dev/null +++ b/src/vortexApi.ts @@ -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; + queryModPath: (gamePath: string) => string; + executable: (gamePath?: string) => string; + requiredFiles: string[]; + environment?: Record; + details?: Record; + setup?: (discovery: DiscoveryResult) => void | Promise; +} + +export interface ExtensionContext { + registerGame: (game: GameRegistration) => void; + registerInstaller: ( + id: string, + priority: number, + testSupported: (files: string[], gameId: string) => Promise, + install: ( + files: string[], + destinationPath: string, + gameId: string, + ) => Promise, + ) => void; + once: (callback: () => void) => void; + api: unknown; +} + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; diff --git a/src/winePath.test.ts b/src/winePath.test.ts new file mode 100644 index 0000000..1427cad --- /dev/null +++ b/src/winePath.test.ts @@ -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'); + }); +}); diff --git a/src/winePath.ts b/src/winePath.ts new file mode 100644 index 0000000..bb8fa8c --- /dev/null +++ b/src/winePath.ts @@ -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, '\\')); +} diff --git a/tools/install-extension.sh b/tools/install-extension.sh new file mode 100755 index 0000000..d168a72 --- /dev/null +++ b/tools/install-extension.sh @@ -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 ] [--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)'." diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..29f810d --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..576f8f6 --- /dev/null +++ b/vitest.config.ts @@ -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', + }, +});