From 41e307b5c156d13fbd522e20f55830b62235aff3 Mon Sep 17 00:00:00 2001 From: Tobias Ganter Date: Sat, 8 Aug 2026 18:00:34 +0200 Subject: [PATCH] init --- LICENSE | 24 + README.md | 167 +++ assets/gameart.png | Bin 0 -> 66033 bytes package-lock.json | 2081 ++++++++++++++++++++++++++++++++++++ package.json | 22 + scripts/build.mjs | 46 + scripts/make-gameart.mjs | 19 + scripts/repair-prefix.mjs | 118 ++ scripts/smoke-load.cjs | 54 + src/discovery.test.ts | 202 ++++ src/discovery.ts | 163 +++ src/fileAccess.ts | 23 + src/gameart.test.ts | 113 ++ src/gameart.ts | 379 +++++++ src/index.ts | 136 +++ src/installer.test.ts | 171 +++ src/installer.ts | 235 ++++ src/steamShim.test.ts | 171 +++ src/steamShim.ts | 101 ++ src/vdf.test.ts | 90 ++ src/vdf.ts | 151 +++ src/vortex-api.d.ts | 6 + src/vortexApi.ts | 56 + src/winePath.test.ts | 65 ++ src/winePath.ts | 49 + tools/install-extension.sh | 60 ++ tsconfig.json | 15 + vitest.config.ts | 11 + 28 files changed, 4728 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/gameart.png create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build.mjs create mode 100644 scripts/make-gameart.mjs create mode 100644 scripts/repair-prefix.mjs create mode 100644 scripts/smoke-load.cjs create mode 100644 src/discovery.test.ts create mode 100644 src/discovery.ts create mode 100644 src/fileAccess.ts create mode 100644 src/gameart.test.ts create mode 100644 src/gameart.ts create mode 100644 src/index.ts create mode 100644 src/installer.test.ts create mode 100644 src/installer.ts create mode 100644 src/steamShim.test.ts create mode 100644 src/steamShim.ts create mode 100644 src/vdf.test.ts create mode 100644 src/vdf.ts create mode 100644 src/vortex-api.d.ts create mode 100644 src/vortexApi.ts create mode 100644 src/winePath.test.ts create mode 100644 src/winePath.ts create mode 100755 tools/install-extension.sh create mode 100644 tsconfig.json create mode 100644 vitest.config.ts 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 0000000000000000000000000000000000000000..4fb647c85a116a45157bd7153bc28e970519a85b GIT binary patch literal 66033 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 z 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≺58oS zj-Vl(<|Q`RsU~P&70{<@u*xZnkaY|+AnW=enEdS?NpxoOXqCr^+P-Zzn>jC zx&I;!#R_m<{Ydk^K8@VKtvi?w#yX$+L&yO!y6c}qZ>Ss2Bz96y#j;Z0t&?|=0AyL2 z*%KG_B%qncp#B3*oqCB5jsL72zK8AkXEGB*Ew4 zGU||NLiY-j3RUH!9q4+DluhOGa^>`)YDRFB(?ZR4FDFFygo3&@jcoNk`|z%P>e(#e z!%Ev(hc7n-XSC(<(0gw6nN4C#&BuQR+3yvXyaL*oz=0&`5!3#)l9j*f6S#czBVU zEG(Il8x(C;E1=Bc1c2D3@W4Mo_M&BQ`U&8PdkGky*wFv8^KDko4lqL9%@V!&O4!rk z9~DaEr?-2xY@WH0B6O=bS0pxHqDK%EcfK z=Vnfi#UHz;`A@vfyEN;E{|cAICGlGMK>c#hW(?jYx1;x=^C!~Cr$1>Dv*Px(yBaEw zmxX#o3u~5@xaQ`+n`Fz8IOzBxV4k9QrKgpge3wtO4X(G{rSkAGKWLlq7z!$AxdPh2 zNNVh&DvEXhElYG>uo6|#*t%A>u!(TdVP$lNH>{l zclrHYInEEYwfkS6ZGb({3i>~VLDny@uHSV7VD1w;2HoDYlt~7962VRe)!G~PlDLCn zbp3DR$^!0jdkl_VG-2WY{|g>b%X~Er*qF|XV|GtV%)IYZX!T*O)IS}+qjL_=y6RUt zy|b1&Y1h}BrU%a*`uC-ieVXsiOuaf70n!rCHZze3{u?|}#2Kf+66#ja%`$aieoK6x zvz#zuFn=pPI4T~r&)x{y=f;CxJo2JJFNEM+fbjU_%cKSI|DRTkePIG{Q1c{8>e${O zsU_V&tu!|DB`_;8=YBlTMhs{lC|~YYhZL^vu(!bZz)2-5q(CDcOsozhQjN-lmu45xGB$h;&TWz{wd7twu-N&$xZPgo&jnMj z2t5&O{BT95c`;F-TcSfN2CCiut2`g(pB=>9|FK|bXx0BqDJs0SU;|}{GrSm;KP_WC zD^yO~&oUMwQIA>z@1j`Y#UB`8r^ws>{B!%ZnrT+A`CiLyP1qZM@@nDV!cIYyMX*e;SaW=7sqPPVwRim|X*WUyy6S&e-x06JY zUbYK(()|x$9H>ehPUAAFboE&_1f$xwQ?0t{`k-gGfdQw#C63*2jM~(A(eq4vQP}dZ z+h(B=*@Asio~}>&Z|HW z7Lc`$se7ZX1^o9B1T2~2>|XuU7Idh1b{OL}!;N<0&1B(_yK2>E`$*0V81fvCx#=_Vrp9orILfb{ zU7#v1)Y(A%iy*sVvf{AjG&Gdg*d&IuM#`92<{FF*Ia$GswF*{~iv$t{7Y4WZ(gqQKkp9VwMX8G-?SRI|P zj>?}Czh7=MB4{i_PezYmFLC*yINVr9i*}RDIvi;oCzqtai~70x`ZrrNLkP2Vl01^V z3sdf=0b&{7LPcK?ubeWPe{XpCu9Zwj!VbVNPE+UsgXmq3ztr2OACPRFSM;eKB z)h|bBbhaKZ@B5{L_UMCG0Y=1~&VnCxsTS#HS|pR-jPCgQl9sX?8(O`uBcLj92z^LM zwj%m3VrKK;(O!JZEkc49k+6P#R_5Z6@Fm7V*3*gPYV}do9GTG2Ji`)f1$h^iIu^EK zGK8le^NC`m5{=QeSaJ2ZJ?COH1j7%%OI|`6XN6-u+sVuaDOi1b<^U%80)lDakU|DAAkQG75T#q=ZjFPJ*p&) z#^q;KZpj$s)TtueFL5;a-xU^d0Bz7dMT6?nmOorr`hXvzB@Ib$^vESx#HL>mfshg} zt1`BBzpQ#}Sa6>>_LoUj>MXYRZqNONab8yb4xVo22Yrc!`e1%4g2mRdektO8<#MWA zyj90BV6WHp_}ZCw3UQ$x8wak`8|kM6{`mQ&>aP$Tdx6nzP(A|;x6K7@Qf;%V`qC%X z9L95})3>6x-@g93inb9DJ$jwVeL}nR&Kg;M6IvFI(7VxDfBVCIvOj-c|C z#v^}Qw;_GS9Q3<}&zKQe^Zn^H4=*hznR=4J^yWGDquj>oT4E6=<*%Ttq`T{96g97S zcH^kq8t9L9y;P7w)KD2sEuslTd6N=fS1!C@Pt_$I`V695{erGoExj~44%~i1vK}dF zBB@TSbSOjjGdA9mkBL|s6+ECY^7nS@gs)16I7@~YzT`wFTzE53*x2^4(8xRwQ705Y zQ;;>DuWhZdZE|2ie3B6|J51FT*v|sxVURv(R|%sizs77nAA6jk zT7h%}c_aDtc-BM$9_i&OaKM)pZpmAkjzYVp-r(Ts? zx1p9ZT_cKR(Jg_Rr{9ns?L_>dz8f2lDG);=5jjSdXh4}7B(vZ{lSkX4ousGa<4et^ z&oLvUYy2ALF-TV^ccSRb^9?z=Bsm!AHx+Ac&VU)Mm;L#B9P=Qw-*FBjG>e(K63Ta- zL3gQZ8p^1e?3Wy4J=T@EL}Al}ay%r=+9DCEb}vLym5ni7&1ii+1~Upg_^`f}g9Gv+ zUMQ%vZ@d%xf%*X*XJwlwMnhdPGG|58h6u;DgF=;tKfe9_ zD$KZ3(e7LMPW-yps~8zdOj3ks84WpJBt=_h)-MT*=NYYrKD`lPTNkWxxX(crv_dmg zUT>XG3Zbu~E`PLcfD1A78Y0ZMddW`PRf0y+1nv~pO>dyRG&sy?<3_@kg{l(<`8gmTbtjSaC!%3s;jIP*WsIjIC-XAv>PW$S}q`q`6#rz8Dj8WiLb1oswp>5+&XKCTz z2^L8|o~4ZTb;Pl&VH=`H;`iav`BFV;p^(~_Ujxe6ENylnl1rzU$0d#bF!}ABpe<1e zJK3~$7mIr#I>-B{@n1x@mV~|cyqHK;JPcIbaDnr|=afd)LDyB>zNiIJbgT_5q{6|} z8Ns|yBhsL@WFiWEQVLO&2;2BM9UuBJW7y3M7>2|=p+ea$Fp6ZE_$JeNK zrt{LgWRj_d%cOR5aTdH*%tvEH%Vb)iN!o9L$2%);P6 zsEWrz#VCa{)n* zX+AVZ9VHKVw4SqURL^y#4kP)P97(8%Ixv;Db$g1p)+kcOA_-KGHXmtM*W5Y zN1d0XM}+wfHa*se%x>PKf*SptC-cq)%A;V3YiRTW#Lj1t{`^{w&MHBJ-b+QQzyx8U zw}dp8?0LON#JnNgMBg_$%$>J2>AS{T7jf$FxC$wpr)S45nK=zc?bg~zz407U!}*?T zQb$f&92jZsDQ!_KV9IgSD3~G!BMW{C5tUA|MPr7UwGv0P0)Zy0tcFz{!WT-#(9i_p zUkvQaB{e*Qs&y1EY4Z=Jd*$btmr9YykP4~eP|?k^Z3Oz=i!nxpkw>&@h51%ebXZQE zZM)4tk_TieUv2YFxQ|WFJKl&51>Q%a-l_M!7xUueB?&d4eAR*l;LTgG zGy251SL`AQ{1)HWb7S`PkEEZX!oDm!D^3f|`d_#`_FJIs)LI~}n$B;$2q?ekbu|`H zr^Q*_EeO=Vl@CUKl~1$YconZ=P62xvssH0HyJJ1kCeOcP7z!HG+OFoCcpm=zkuMrU z*~Ep#Jv|Wi6xG4oeJM4%HXnXpa{vxgY+C<=)b$HSqxZTJLn^hE%3a@)a0tdmeuo=# z2EHrrkI%yaLX^rU*IGhZ6&VXR(ohxUVJpNA$958aV#{Nu&8*{eA*h!B$=L%jXYrx2R-1G-;K#}43y?38d=Q*B674^1)Z z6#cILsFnJDzFbAwW>fW0;3??Q3HI5)fz~qRlU~`dnxW;J!^ju@o0f-4N!og&P4pU2 zBAfD8=2Y8!_8O==Y%JQ)4y3-y+|)X1oGsxMKU3w3oV< zh-e>c{*W;wyA0QBzWSkX%6Gz{@t)9?SRR7Y+mNgnn{i@unx4MKdCn_jf}UjY>R@?Y z>WkEmCC=;QL)w7uC+L;#Ii3v7tjjVl3OJV_^5a=x^n8nYbWH^8=dFT%lUb6)VQF1K z(O@L@Km%0CTh@rQci8#U46Zk4u)+pUF9uw7ERy64hLYSJT0=-+CAjxi)lY`u_;v65 z;r@ADP84H|-4xIo_pNJ${7*d^8YBTAXMMlmfX*T6V7xE3^z%&;I-F$CcjxzIwZG=DG$J^cR!7Cq_0vuaXh zRTjA}KNt7XwHXb<6p) zv?rF1C+z#OdgSZw;oyBJ;2g~oAf`adD>!pq*eA{kdk0FiEFjAgfmqI9)Df7@AzXMT zD?B7W3~>qbF=iLK&po7o-wZ_hWe}Yyh;fEw%_dG7G|r#Ve#1rbrqhhb*u*BHpDDiL z!J&e~Qhy~~X5LBAosafoZ1pJeHHHbpyVvXerHCrOEXW9&j19K+Gf(ZmS5PbXcu|mih=R__~#`_1;`!Qn~oOwL9!V{MWW+qWWKR@5`hT)GfXE z{|)xX*q`wF!*3|&^I-pPx9bxqOjk|tyHSf*SK>l$psvziT`2;~Y!M>8LVt`x0bU_r zyRaY)?ZS~B+J%xhhJ1X(IF0Mqh~8p1bX>RCCzPShM4TdnczZmT8w!lHE!!-VltW& zV>A-fc$3O<9+k;;iP`afGl7~!agX*xo{o}KF#Q*23v7p8!Yvc?vL~urCYk}ZC>a-E z2g)sdq+2My!fOldK{#A1}Z|) zmxLEkdt=!}X`Tl{}v%#B&RMwq?~-MAm1 zSXOBGBxfc=7?C7Aq7ccH6a{vQ+$au@L{oxdlt!tYh=no;qwSRAQITW}no3;8R*_Jx z5k)Z0Obt1;aK@vlW1~LB1Zo=ShyhG&h&IoPO>BrwY&3|r%r^hmX(RS-bF5pM)GbW_ z(bhSxTO4+qYuPeM-7={Oz`l`cx8~TSW_3$5K(uYH>lV!2sK01EH@<(kbtB^X6Wlo1 zZpmi@@%-?8KK19{+AgB>(6(rESYVAX$nfP9^l0LzDf~J|=|~Ea_>bSMpt~od7sKuK zY~3ZaS2cCJVK{1+jqcXblAxd%Ls!_Oh*_v0C5@qc;JU~`L}i)BNO517*)`E6dI4sf zq*t7hQT%|vIwYu(pb9ys1!%|lOI&%U2Xkra#__Hj6R_P9B|X{?c_x&R&Td->H}<-2 zv2HXaHcD(MlM+~F1-h}c*L4g2M!CL=cM)t`a4tUj9??Guz!u;ML_e#T54ow0_zQ<< zBSkfQ+L$lY-7^wV6!0D;Mk5$_i~u8(M#dPgEMOeE_Zll2Xsis?Sm`LU;w4?Mjoo(F zQr$Qny+!TMP%_J`v=XJTUXyiWFYQKFnc}#yOumiW{!A`cp7XdSZp69ZICrPnyJty#Zdo3FQ`J_}gt8lcS#1?ch*o=teF( z3;2zsf4J-RUQchX`YbU%lcuMdu4y>7FKA2jX&D(faSI~jr-*=WnY+gbuPzc@212NO z#L^*LwO~q6W${ZFrK!d$kU?X`(pzImC$k*Gz2o*C`1Vr#?P{M?+6(W%NVYq6rDc6S zZSTk5j=i%kYn1IU4J2f{=s(KuJLT{=5j~TpXPCMzAlnpS+ss&LC0Jd>WKmZI$E~{3 zEW%t9c|4%Zv+*u%D#&3p%AXDwiI`+Bx0SiMz%3OYnbhF0y%D~Ba--v+QXg5(#K}!i z(;-T}-KjERw6NKi?dnG@%-dCusnEgva@bDs6<*u%zQX+~NpP<@J;|^Zf#10B+Nndk z_M;9s76j`DduZqJLPR&pwU>9P`N{70k&x6>S>C7?enaC~#RsP`@_ZdeOAai~86cJA zX@ztlDdI~rX1rGz*oBfhh6p<#G8yF*P*@V`M@^0&6}o=pFekCF+3BMw_D8rz&y*I#I%tSb zz5S>ad6KO%eB|~;DXyf;7p>0;-GhhuB;z#@!WKu>gwc^fjVH&0CwQFOf|5FeW{Z$r z#{_vS?s=@VPGz}dv(Rsxs8`ieltF-aGj@4zc78#3OH=itzF6MtG(2DZ$a#c*d7op7 z%lRYor$jjt<r;pMthztv^litzwT_{P185IXi#$Fo zL7kR~s~~s9C%jiO2<##gj7Fb!MixaoyC?@5K-m5qH=F{$u>*%a)qI*oTQZ=qtaSh zNoxxh#Qaau`KGQEffa&VgvWzdn2vU#ypEx3PF~ISQ+(>;FQAPH_0wjTNr=cDB84re zPp7mXYN)E8qADLor?k?2ieY=TAwE@hC~EWNQy+h|v?u0zzqEVJ{V8&IN?o6D?AF2B z^pNd36%#l{8<%L7@t$Cs*n+Y6=k~pO$qYK2>+-4EYYGSNRHL zcA=urrH~JD2lS`8>JI32Mbw0i^QV>lE?=({_;u08NB`}{)f04gT<^Z^$>H^Ia%vh_ z4RbVg+lR&tMC1YEk`lF;;$9Ll%g!K|pb?aOqF?^c!4~AwcT*|{PHK-#EYS(9Kx2v4 zSOt=3tY|TgmU8-33tOG&EPAZ8D~<}9IC9GAQ!Q${^$M;#uysr74t*p$(CDgIMZcTy zqTc~qUbyZs;HGcH@8Eh4*ng&oOO1Iqou3D~M^4ORPtf!zTDMDlFk%Z*RCPjYp5ete zScLRbT@?iCN-A}w2&|kDlN1UjQY#s7NsmN~JsFtMI6XGXbCmo7yB3lSEL|MJCpTTV`lKz?oDRZk-vKRHst!M7V91 z^QR4wNe$ul+1d{ZlOcrL=UBHi+6i~eg>Fyn(5dcFsL=!^+&RzbqvQ@<^Q~K&0m5Ai z>>HCzNfPc}VEhcHd!hP6i$T;%q{9jE)^cK2;hNW_Bij5%U?N*x|)ho>FUUWi(p+Ek5}i)h-8Z66Us zbo9t$ib51|tX>O)$$01-GfffIG4>kqITf)9g1ohNaOy-b`yia|4B8KxJ4&pMRqcY+ z?l=U;p>g#kQg|_2SLEhCorT&@5(Z*x(oXwQ$}w}vY`6DIWuLNcjD33C_bKl| zPj8Ht_qr}^fwfOXAL9<{KK-ss5=ATfpol# z;>|CjxO&w02zIZDo*CW`Cx@0|v`6N4jT0YCamKVXH6l_GMm%WbbQpndi@=-*q(_Iy zwsbVzGBw=ShbB-a92bPnVjPDaXe<#ji;KpJl(b>&)9$*EVx+W#TuZ_BDeHpDENi&L zkX+^6*mBCHibS*mM=)XRQ`yI9pMJu99Qxxb4DD6rE)Fnv)An)ED^(bpMy7p;{&p_A zA@3UX6C(JDC2m2~Z5F%jJH!yPw9;b{YfMvOquX+RdW>a_)n2Kq5rMkmrLMw5^0Goh z5;k%P5EEY}Cn98};sClmqv1)lSda4LI+RXTXU9Y)*Td!;fH&x8YATk!|(T_Tc62)X&Z1as>}dtV?R^CYMw1YI#I5k(&$`b31V%MzDN5~i_+a?)5b%B)Cnv)yfUXk)FM^p+MQ za7PA2$~vGj%NCoQLKqO~x||k@v4zfV0m6%8chbwTtK%fUh&y#<&uSk;m|v2Jzj&&~ zzRuts@j^n8h%KIvy#OJF+6@t5?1jGE;*~{OC8HlsoJ>VjE{X~s?d`svtp|}yT1BO( zr{$5la&mBhR|gipl?U!Gp>?aSJ_y$md1Omi6?l!}vlVlPG{hxQJ9dK^HAghe5!mxm|{ zUVa}1S)#>?oOvvfcA+Je6)9@M&Rw-&I);&wR?I3Uk;P0tqeQE|WY)`6d z#qmy&+M|Oh8LKVTe(#(a*^cENkZIq`>CQd!n@XWOZ zJAU3&Rtu2<*a1H3U0PuEp1v;leunhd4Z?o@^T9lm>)p2gzxs_n&QjR-Y9imcf8;f-3GJ>6>W$l^0Gtt#xzA4^3 zNDR+2QA=@YgRZTcQ`~TlNDwP98S8|^QoJY)jsIMGmSmrWr#vuvLw{|-uz$9oiFj7n zWwB>oDhH9r@@9P=RO-tR+Tke;L|Q!d0hfE&rGn6ftta_ zUF|1Q-a)j$g8B+rfXB#a1@4<=^8HdV6h>mAzB-R2*uToWzuOD6Vwa2}cr{o29U(g_ z`zt<|ke${22Qe*FCAU#SkhUS+U);_l`vccRf*U0C(ZYJpp)H7W3yoIxkj08*Y{462 zSKdVwp#YJBXI> z!->p@$j_-~F?oWio{LyZA(#IfX6t!oAJ%(fU5y)=E))Tpyda0g{dru1OuNnG%Ax>Y?@1r1#@DIq7$}S`_q>)cN zDwRc=BUQda>;|@w&QerjF48o_umv%)S%mL!%2z77h!R@Ln`LD;enQJI3NaH}bT8A& zSE~E)(^_;X0g99c?N%E@_V7uEC@~`lYQDMNPwH?WD!!!sQ*L>ncVnI?2|$(nj)D zcH@otO)c;esUjPGp?syfm;5Vzq+e0KRnzafY#F$U8O2v>2S~p{_pM0nAn{k~25HW% zZcxszQ2ru{>*m=#rF=w3deDMs+Nl~1OzQ$+%pNde4oSSii*K$`lvk{_N+P{q!0%9; zdmrjP4o-+9)d%$2_$%!=YXi=s?5S7O$;ufLXC`5WBKejxwuE9TV$QfS8g)3nB7|Cy z$N{n(se11xRa4Bf2u-e~SX9yN(R5%+J^5D}9hPN8%FrTM6U}xZT-@xs5A0hl$Q_15 z3751weZ=uA%C}0}sJ_)s^Q|(LZ;ikwT-HIff98IR@tS+`RM0(voEr#r`(=m~m~ja@ zch5Nnii)(UsO~`zV(zW5HOIs%nx0hYe~Ei-?7rSrS74|S5GHk$#lDs^-p#0_%VUKm z)le+k3IgqHq_*@K_ZvcAst zdh@ESehdG#-kc1eNmNjOks_7C@U5woe;U;nz5Eu#uk-Nwb9=P7KmW{a&DO3lQ&wOk z;U2Y65mI#4s*N;jh3spFanD_udeWgpOGi7T3AiYPd~8j^Eq9g7U~wsGrP+-+6DlZ1 zZiOaQ(HV=O!dkjwry-?C3C(Q8P;nz&?Nc=|ORn2WTVcgfG2Ha>cFKyW6&-Y|D~2n( zXck_q>Q4O^qsUV#fL131Xp9imj3}TWQqzZ7b;8m^)Y9}u>K6wnzo-~n-hGkb*G)~I zFAmCoBbc5U$WzL(MQ~_?K&yuViyqK8BPymLq9}Iw#f#gRJ%xM!;@B&3=?5Xh^YxyW zxX350ugA;mxT_0@Vlk8qd+DsKoFOr6;yI%WiSlE{So)kX#dK230>Q6r-P zTNHB4qom@VN0Am5+XeM0h+Yi4dMC2qL^*&;Pfgp)V<`lP_AqM_GUG3)g0NImR?fJR zoN=WF*|nWH<4aY_V3g-fuv3vUp(4AnSNUP0L8V5>%%mzPIW2>sN!1FI0ct=BYeI#! zW^&qR=s(vfOtDiBQj{d8Yfh-B0Y+n}*oh{MoZbcD;-+wknPx{yo5Q6o8ae%ojh}T| zQY^k(&O{f-|%qBDF*egchC|sU0AoE=6e3TGM|Ai3lw^ zi}WihQzLa#LyOPWeu&hQ5nA#k(ythV)9|}Ae=l%_$?p->orOM814K`z{v?w;|3KRz zZ+&W;)tfM8dxysg6?=Gb2t-DVGGZ5ItW%?+DNaJ8s6)tAqOls5F`~B~=b^oTg00Ii zcPQ4r#@hQdqxvj!*QWM&M&IYyITkfS`%|@_)w^ALcWm#nwXPiO-wkRY!T?YXs+>@D z1zd8NWl2s7UA-83M!_a{#+N&&BSknV3d$s?vO*^$6!7X)<%}<*Ui3z_JahRac!*vg z>M+(uNnZdn{Y9mxxgvgkIxkVGbbd zyNbg?`YjI54vU?{`rZ1e)Zb>aDKVJ`*cd-5HXltC@K&-diCK4-f zC`NqqF1o6*JI@aB1d;|EAW5 zQM?LP1iz-2DS%MMA87PrpG7pBG;4*3|9BIpT3A*F#OwP|i7TN0B~4t}dVh-HWlSqW ztNO=1ab=JdIw20C=*LpVy7D=yfTb^ZKe?K*g1nl3#2qINE&Qq`Kbe+F43{>uE}N(% zesSk2LMzd$O?ns$;mXb^wt9oa6lDs}a26E50N6;nGD%IJ@pFs{wO<**g4(g!w-br8 zL8iV<>Dmi|BV@o4$e#qvr_qZR0d1AIxJCv~qmvOE8KSE#KI7Eo!s{cjdl|5wBJTB^ z`bzu}`fmNT`y4=Bbc^E_9ePp^RnS z!z7a`80vwkOs;aBW9XKdt6?wUA1bP&OYEiIFpBG$97Bx~Cf`tP^Gw#E;w+TWyN9WR zl($lQh`O`GarqtOHDRmoKgS^o~R0>{zL7EGCjQ*afLO-jKs zSX@(CiZ+~7$zc&k<%!8<)*togY!;SUFKML6X4zglTG-bWSJTA=*(`?y6i6p3)Mv?x|%VPEB8`-aS=Vk7kbu*Y;C?*K(Y?0S*VLAEX;< z1`w{FO8OPZK^iz5L_N;5Ogad!D?@dUh@Kg*pI*k+1Z~%T#~CByF-J~<2<$u-O79C8 zdlt2CVo%yNa#{P&+-oD(+hNoXc%$q|978Ahls&G*!6J0F3$w=*(#$Tjkv*;$e|Dh& zix3olIqWW>cAUa$%&MerPg^aQb-#0p>lsbMWGgCZ;9wmpa{Q@7|%0vyTET>jO`uI3be%^ALAhq zpV{I(S%2X;z8mPj==;G`f6A60a_`@gwt1AxPqSDzn^3x5$q41qP zP8y5*)5Y_duj`jFJ|v?Sm$y@jSc-UUZs%eMs*_B@lM1`6~WVIT(=vj#KXuID`gYuL-F#i6S)*%Pf#>P70 z&6LJ(MlaV*HicLj-D%sNO+!eTFooEG)KzTb;=<@hYv*}1#X*zqP%0@v zbt`ZNvuzR&qjrfKlxz8s#k4%;cq^zEa4Gm?>Y+IFdL+Fk$DZA(h$upDu&I~Pg_x=d z7=snh9$(^?$BN^t8P$m8CRUWEVM+~kv31Ck%2#BRHL)0uka$ti>beg>eblNB_NgeT zqg8iNr^?_J@M?QF<KMTFcH=X*>kr%Ic6C2W$7 zq@1ad*kzXTO3g1K;Ds1JMHNPjCR19fCo){>9!}}bslN`@2h(I$SLty;$7|@zUG_$4 z4N&VU@>cAP*kX)?grOCT)mXZUQ*Xk(zr)JRa$?5YNPJe6gTdz8Q> z9AnCi2{8&;?LsxLSkBQgG7Uv1$uU|f^_VtF_YiI=mbX%Sh>os` z;`q>;i7-Qor-dZKtduV!Lv)3qz?aWdkqA&}v{e^Uu>%w6U9pu%Q5*d~@B& z!`Ae$SchIlShXj>^$_vJmIz%XsEZY)22#hATwr-{1=8UezGx}NP@>;H{fY}zbsTT@c5D-`q2i)w!u~STTAt}zL;rJ5h z_7Ek;g`>|sx@cR)`sF4iLqif_m7Jp5u>La@4w>hYhA=;0((-DejEf}w7Jl-NiHHrQ zjv{P38QBn2D<{txT9dJ=gR`Nd9kyD?Vbvpd28R`Qh1s45$LG`W>1A##&=$wf9LVD} zM~@+z;f>`$MPT}XZ}b;D55*wVcOw0uLnu_BYR3_Xj&iBwo;{(|z0|u3 z@%2@5bliqXS%j^UQ6v}bnA5X~MY0ySeH=EzElh3_%-bV#$1NKVW}Xmj zkYDTO7B?_s9gjg~YJBEZP>QWj5z-G5jrzjtyQ%8AW9bbbcZH>V&>AUAA9q!Pc{mE0 zr4EH-QM)4Uhec-bkW*wbOP%GBCj#dd%SBXmvoCZFPo&q15>?nSomN^k3__OKmb?2D z+v_VX9yhIKlR?v+MUo_E?&;O$_?%!q+gv8%2~}FO@q~K}B&*}4>n$<;0;k)$>8Cloy=t#NYvTHGPfn78SYJCwZlgVGxUn2q@M4Pc{MmCK^P&c~l7_V~@g@qNAiuFnt4 zoIA%SpJt^z?k(5-_+aGW9OW1yC#KA8!&|lw2XDlvYtM__Yl9Fe2Rs-x4@k01LlmL0 z@W)`$X{_wRD#ELfAeC9!#dYEmJjz2^*34`1M8`!HE87@zIWBRbMueiWroNTXhFbBO zHFP%gi#V*sbZ8vpJ|D`gw?s=TFVsWJbm4zO`u_^icL98s1U$5ATi_$AJZ{m3t};a= zQDDSXro5fGUtpA0{CYRC6dg|QoP(#XSTzQ<6$sZzdL1k+ku_xWBs{N>m{jIXsrMBX zilQ<3tv3YEOe%K^%Xwt+hO>)n1&vxuJ+-9`;*!tFU!lAY36!=<`&Z0`T%qJtwG00h z)g6{C*?A&REvh`BC_ResU(uBbe}%Yvfq1Ucv~^Upa;*2w9I0r7A#S|&t=GWx@*H|V zuAQvXj|-LxCN&~R`DmIdOS2MlXmHkalgh2BEIz}eKxJA~LoR4YTSX(6y6vtifu))k z=F~+s+UAKSr?#8dQjDm4m|`X53ub7jaRARoWEhIuG!<)4#vT^={EOgqjk^7{-5VEg#gQz~cvu<%n zN)fDlqh_$8)EbMxm1NUc*;A@{3^sR>Spt$ePS1S{N1xlVBz<+e2ay6VBykC^z2Zfc z`iqp_!f^l@FR>MB8f5D22zv3UUTB3DFBD1U{UWlv!zASg`HCv&FeX^r5hS*H`RXd# z$i_==weyu$MX>(X~a}gFq@iU{Kyd5}@>$290#?=DrYcoABvrZPkjnpmPYo!M05|~V(cRMnvFdm5t}Ss0%yfU#-P4owL0hl1c2x!L zte58v<4!Z>Pt)GEv-QQ9&iX3T#cvoO#RjK4{IG_K4CKf{lvjXBQB8_@A`ps*NJ2^} znyRFa9iBE|(}g*qtm=6eDqUC@%BnU;;1~F4X7q6m3HO&p0WH8t)g92UVid2k+jeV~ z$xk2g8vmpt7PDuN^rBj@=Y=M!BtC0S_*pdHEA_qz=El^ZM4fnL;l;FASwczFs?fqC z9q~i1MJx8mRtEqJDyO>!fh)tb;AtZcR26A=hZ=ZQR=q<|m9-5XHb!`bSx&0Cx9hF9 z1TW7bG$nbYMF~|n)<3&Q<>m>tcuSS8FiX4W>B{4e1AJ>hrBs_a2}o2NNGrpV;1x*R zLEYI0X#Y9Y{;TE$H(nbMt@0|aQcPlvRy7$)RjR?EOcurSqDhzD0YiwxgJ2y^O%D=k zV_mxexlfL zb?*Su?Gy9}A)sehX&(wqFBwXsR6!^A{EMH>ioX)leZiM~GqN#1ym~c(?T=8rM1>6k z295$FEBdvd+VP!j-}s)UdUV@7X7Yn9zxle2o<16EzO61Y5+RU3Q7TH<{fSa;K9TsT z+Pz1UkdNM6WwjoaLrrp5d&3%U<4r=;GDf8hofE}gea*hC`yiV>)p{C%d zdfK#QDSgbUUI6m4DrQAnkWbicsFJHyzfH)@TIRgvv$)-c@6_7*9_OJbL*s^*OS;`NV9L? zcc3ovL-4RBNU&xJs5GCtcG;<7G=dM73eP+8;(F{BvCUD<_O!w$gS&mIooP+p@?S2n z>pTZ5)Qe6t>zqcvA>A5Oe67&r2ga{0ym`7NcK;|1zzVM8m_1XL4{twr{<8nv61`u= z`TPs^;zC=$fL04wk(CC5R2{{<5~)v&yfCt(*P#wg>A_OPl=75Ws;>R5KyeDb0ff>V z5_3223Pn83%2=)>Ts6Ko{aFvVO8&VSeh9DOYRu)kK`Zgmix+nv{rh|T*6{CIIiWUv zbhENxRy464v#s1WhOL^Ra(ots3_LXeTEX#&HYruY;BZ&wA+oRMFsBhiZG~r7Z-4%P zq5Mc@DAK_2Cl*y_RcnOZ`63e~+c)Gv!PuvF=ftOkwx&?@r#W`^>2SMV?T;Sb(v%F! zxBJ68ngeJg-`$Tl?sm3UyK|Qo2H3u#T6M^ieLEg)C-;Y?lsDHA#Oc2shSA}l9aW5Z zDE!vKPAulVU2&8Eu@^(}>~BwKf?@K$sr>~($JET-(&&z+r9$QwbMoCuF#q+_#hzX@ z2nS!HD{@A^I8>A@+kd*F0U*46_3%%|%%@s+@T;D{?tz`=+p+m%l$r8(q@1VV@z-4# zaFc!dw6m?jDlSnEPn&SJOq*a<^|FUrb*@HK*EBJq%)L~9Q6uRSk-3$Gu`j-IRpelP zS|&ZM9sHAH2fujP55P6xK9LcX^(x>=T1+gVVdehD7r4skDR@J~=2$W0>Jgw-9(84M zQLM;43=Y!z^;_!yRJS@LP$-(sC;w-7M6B?U{D_?_ zp0dZpR3SNF>6M5I*{K5CZNVUvrC!?)et}P-aeiy8b45|1J{d?<8|eJ|gJK;UNEDhF z{h+BnOI5pm$oeZo;fAch>iT7gA;*C$4%sm#0VF<8Lpm$KE55PT2_@-8QQMG#8He(2cS>ogZBvBb+me_oB*9Z*qybpLvf1m7hVn!d*bVa@v5zB ze8f~+ew`U*q)r^4j{F5>gq)d{`1O(wD2xt_Ny*e$E^?1#i;7HL-k6|A+7@4yy;SZo|;Axj7*J(?>4 zAdclZ!Kx7#<)sM2lS_xiQVRHDm^1{QlOV zir53)rADd|k;Myk{?XG2R?bsmpwx^)j8VB+cW?*v6s!}La&Ys_l5>#M-w`k$7!^>K zcI~p0{Hh!?L19*S(V=W)s(Rq_>bmIPe9RZd0tovH>;iDZ(<|fOjDGMKRn_SaykBnK zS84u4*#}?sQkumxGj51RwS3_?Dre8ia$aQZ_H$Jicn3eBM43|03q)U^&rA#=N*Ur>+>debBH;)KU9^LpG0B%#`tGZVKfvO zijUMJs?aGh9IPDPvz9lo;VsjFjc)f(OmAR{bBvZ76jmh$G!WP^M9Yj|`HxunN^la; zqziZusu&=VWhe`$5d{F&gsl-Jrf5d0=1zK_^4R<_-!7ZPw``&d*x|0~f@743a|8?Y zx#4u5-v&>!0DMMEHo|L3iddnWTfvXL!Rgj`n+Yz6&_|$Sqp17C_ z&eD$gRq$bN#-TVpbH$GDm=K=CGTwZ59q`W`-uy8tnLlQzDBE?+RuNz^9v;`Hx0!W( z7X|g2MYVOL#q!yn!niXh7#OK}SZB3UaX!I_a#g~hmMgtq7koK$m1&dxfJFhhbz7@0R!QcsxDh8R&e0WU~Sj%L8fy zJ@O!6#o!w+3>zzgk`cpX5|mvH)@j2oV~9>RVPn9PR1zz;H??c)fFGM;2eM&tRkIU+60+sFbkRA zVK6J~-Z;G0nx6%+f-U}Nh2hWMCtY~~Sa?s8`JCj3SyaUmD0UM5p1wlA!=X~D$b-|O zRaST`lZBL1gu$<;_8CKh^NGQ?CUhk}N@y)?aOm4sOp1JxfJI@ynKh)6q&$YS8JUu% z-nQc$-}y04o99WHlB1iSc5qO%-gzew*;#%ye{|>qXbSy12MAH*rMDDe@U>1>GG+Uo zF1oCGxaTdl&IG#<9tt3;!xhV#4C$u!f&b2%VT8c<*p){SES0CapQ}aztp_H(;1Mh; ze8@@_3Jd%cqkir?-XDqcAZocHrmDCKP^n+A6Lk;6?u!q!EfCumEP8{#SzT;ion6&OSP8Gj9MX|Gc5}(bh^uc>?>*X5FYM>)AZSTHUK&A6vxa$kkJ z^@8TQo^bw?RhgQi!sW9Ul{bkouU`Bcv+Z+s)RIbPRx??%xz>YgUA&%^<{ORU;JB^X z1Cp);U{_#KaarWC>6-7by?CrF10tH2t;7k2gUpG46>f}5)xk%{raz~BeTTJ6+ z@U1Cywoz-VdMl`3rFGukQ75=asVj6DS8uDvTK$BY{h&FJs5RWtNa*2@;A#}pc&)G< z*L6ajwI&jEZ)BPZ^>#ITRX?S9LW7MKaRq<L^2cL`m`^_yIi zweAaa^RQO%w|{T`oYePMO>^`kt(jCm^E8j^QB{k_;2-}97f(ide$~=gFPT~uEBNPs zbN|8bxVkl0@UQ>b{5bkoGk@l9%6YTKvt={ijHYWe&mT3GMtni#FFomM-Cfi<7GPr~ zWHuyWTM8}G+Hhr=wYcd^Sw*Sk+}PDD!<%EPhcT<3(3|~F`Gu6%*m#p&N3(W7Qr{%? zVrvJN@_I3cxbcz+*ZJ_rt4rN=wNj7V?5BPA+M&l?eBAY~VIV~a=N$=1?7~L9l>m|r zrL3Vv&`S(vP8$;7NfFV>c>92H?z#lAQ_IK8B7d=(0!1$Kc@`U0B?>( zFyHF7Y?qmc*-y*cyItN!A~IGU9B=e6MF{fbc~mv2&3R6;v^Lz7W9J#T9vlzr{GJ>g zwoxl`4}z+mem#l)V3jm?1-&Jnd$gr8isV+HnT1>cD4ozhh~ z0L$)OxM}uXYRvNA#%?{uA$znk_S(GAD!Xw0%q~SJm*b3Klq>ZhmR76+sQ2e+NoV5x z{zr&}$h-vhw*;A~(xeDW_(q7<2r@Cq>=k1fdk_x}_v!d_{G6bjICgSZicwv(8CK96 zu6A+Bxt9e=vUt_4K?3m|2CgWZ&PVZ*4$@J=Z@)KxDRpzyAMA-bp`7#C~BM`V0Au#uxUO-(M%`lo3Tn)Vh3l$BG&HWLL ztoGK^hY}?(lChA*qwn2`aw9#@W&U=OcUPADn%#;E=|pTTxr3&ikE4!SX(O5{pSS_6 z49q$?w}(@2ur;JBmZ(&C}-5o{s@|VU(YRSX*e%! zQOjHRhj4mNQab89cJ)~9_=ot8tEcjRM;7l9zs@>ZmE|@*k##*NIZeuaB`bmiDIY&W zOA-F%)J@!o9J7LEuRQG`awN)5J->=aQPX>;-L|M)^5SRdvE%&!mWy}lu0z$%3N3}S ltggpsW$Ht{LMsa{`ah_=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', + }, +});