diff --git a/.vscode/launch.json b/.vscode/launch.json index 2fa18be..613c990 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,26 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}/src/papyrus-lang-vscode" ], - "outFiles": ["${workspaceFolder}/src/papyrus-lang-vscode/out/**/*.js"] + "outFiles": [ + "${workspaceFolder}/src/papyrus-lang-vscode/dist/*.js" + ], + }, + { + "name": "Launch (Build extension and build and copy binaries only)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "preLaunchTask": "buildExtensionAndUpdateBin", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/src/papyrus-lang-vscode" + ], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/src/papyrus-lang-vscode/dist/*.js" + ], + "cwd": "${workspaceFolder}/src/papyrus-lang-vscode", + "internalConsoleOptions": "openOnSessionStart", + "outputCapture": "console" }, { "name": "Launch (Build and copy binaries only)", @@ -24,18 +43,29 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}/src/papyrus-lang-vscode" ], - "outFiles": ["${workspaceFolder}/src/papyrus-lang-vscode/out/**/*.js"] + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/src/papyrus-lang-vscode/dist/*.js" + ], + "cwd": "${workspaceFolder}/src/papyrus-lang-vscode", + "internalConsoleOptions": "openOnSessionStart", + "outputCapture": "console" }, { "name": "Launch (Build extension only)", "type": "extensionHost", "request": "launch", + "pauseForSourceMap": true, "runtimeExecutable": "${execPath}", "preLaunchTask": "buildExtension", "args": [ "--extensionDevelopmentPath=${workspaceFolder}/src/papyrus-lang-vscode" ], - "outFiles": ["${workspaceFolder}/src/papyrus-lang-vscode/out/**/*.js"] + "outFiles": [ + "${workspaceFolder}/src/papyrus-lang-vscode/dist/*.js" + ], + "sourceMaps": true, + "internalConsoleOptions": "openOnSessionStart" }, { "name": "Launch (No build)", @@ -45,7 +75,11 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}/src/papyrus-lang-vscode" ], - "outFiles": ["${workspaceFolder}/src/papyrus-lang-vscode/out/**/*.js"] + "outFiles": [ + "${workspaceFolder}/src/papyrus-lang-vscode/dist/*.js" + ], + "sourceMaps": true, + "internalConsoleOptions": "openOnSessionStart" } ] -} +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 545a5cd..4f86bbe 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -15,7 +15,13 @@ "cake" ] }, - "problemMatcher": ["$tsc", "$msCompile"] + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [ + "$tsc", + "$msCompile" + ] }, { "label": "updateBin", @@ -26,9 +32,38 @@ "--target=\"update-bin\"" ] }, + "options": { + "cwd": "${workspaceFolder}" + }, + "command": "./build.sh", + "args": [ + "--target=\"update-bin\"" + ], + "problemMatcher": [ + "$tsc", + "$msCompile" + ] + }, + { + "label": "buildExtensionAndUpdateBin", + "windows": { + "command": "dotnet", + "args": [ + "cake", + "--target=\"build-extension-and-update-bin\"" + ] + }, + "options": { + "cwd": "${workspaceFolder}" + }, "command": "./build.sh", - "args": ["--target=\"update-bin\""], - "problemMatcher": ["$tsc", "$msCompile"] + "args": [ + "--target=\"build-extension-and-update-bin\"" + ], + "problemMatcher": [ + "$tsc", + "$msCompile" + ] }, { "label": "buildExtension", @@ -39,9 +74,28 @@ "--target=\"build-extension\"" ] }, + "options": { + "cwd": "${workspaceFolder}" + }, "command": "./build.sh", - "args": ["--target=\"build-extension\""], - "problemMatcher": ["$tsc", "$msCompile"] + "args": [ + "--target=\"build-extension\"" + ], + "problemMatcher": [ + "$tsc", + "$msCompile" + ] + }, + { + "label": "tsc", + "type": "shell", + "command": "tsc", + "options": { + "cwd": "${workspaceFolder}/src/papyrus-lang-vscode" + }, + "problemMatcher": [ + "$tsc" + ] } ] -} +} \ No newline at end of file diff --git a/build.cake b/build.cake index 8fba1e3..611c258 100644 --- a/build.cake +++ b/build.cake @@ -210,14 +210,17 @@ Task("copy-debug-plugin") CreateDirectory("./src/papyrus-lang-vscode/debug-plugin"); var configuration = isRelease ? "Release" : "Debug"; + var skyrimPath = $"src/DarkId.Papyrus.DebugServer/bin/DarkId.Papyrus.DebugServer.Skyrim/x64/{configuration}/DarkId.Papyrus.DebugServer.Skyrim.dll"; + var fallout4Path = $"src/DarkId.Papyrus.DebugServer/bin/DarkId.Papyrus.DebugServer.Fallout4/x64/{configuration}/DarkId.Papyrus.DebugServer.Fallout4.dll"; + var copyDir = "./src/papyrus-lang-vscode/debug-plugin"; CopyFileToDirectory( - $"src/DarkId.Papyrus.DebugServer/bin/DarkId.Papyrus.DebugServer.Skyrim/x64/{configuration}/DarkId.Papyrus.DebugServer.Skyrim.dll", - "./src/papyrus-lang-vscode/debug-plugin"); + skyrimPath, + copyDir); CopyFileToDirectory( - $"src/DarkId.Papyrus.DebugServer/bin/DarkId.Papyrus.DebugServer.Fallout4/x64/{configuration}/DarkId.Papyrus.DebugServer.Fallout4.dll", - "./src/papyrus-lang-vscode/debug-plugin"); + fallout4Path, + copyDir); } catch (Exception) { @@ -353,6 +356,11 @@ Task("update-bin") Task("build-extension") .IsDependentOn("npm-build"); +Task("build-extension-and-update-bin") + .IsDependentOn("build-debugger") + .IsDependentOn("update-bin") + .IsDependentOn("build-extension"); + Task("build-test") .IsDependentOn("build") .IsDependentOn("test"); diff --git a/src/DarkId.Papyrus.DebugServer/PexCache.cpp b/src/DarkId.Papyrus.DebugServer/PexCache.cpp index a00c0f5..d6309a6 100644 --- a/src/DarkId.Papyrus.DebugServer/PexCache.cpp +++ b/src/DarkId.Papyrus.DebugServer/PexCache.cpp @@ -80,7 +80,7 @@ namespace DarkId::Papyrus::DebugServer } data.name = normname; data.path = headerSrcName; - data.sourceReference = sourceReference; + data.sourceReference = sourceReference; // TODO: Remember to remove this when we get script references from the extension working return true; } diff --git a/src/DarkId.Papyrus.DebugServer/RuntimeEvents.cpp b/src/DarkId.Papyrus.DebugServer/RuntimeEvents.cpp index 93d7970..aedb606 100644 --- a/src/DarkId.Papyrus.DebugServer/RuntimeEvents.cpp +++ b/src/DarkId.Papyrus.DebugServer/RuntimeEvents.cpp @@ -523,7 +523,7 @@ namespace DarkId::Papyrus::DebugServer if (tasklet->topFrame) { // We don't need to set the instruction pointer because Fallout 4 assigns the IP every time an opcode is executed - g_InstructionExecutionEvent(tasklet, tasklet->topFrame->STACK_FRAME_IP); + g_InstructionExecutionEvent(tasklet); } } // TODO: There's a second CreateStack() @ 1427422C0, do we need to hook that? diff --git a/src/papyrus-lang-vscode/.eslintrc.js b/src/papyrus-lang-vscode/.eslintrc.js index d95563b..4922fd1 100644 --- a/src/papyrus-lang-vscode/.eslintrc.js +++ b/src/papyrus-lang-vscode/.eslintrc.js @@ -9,10 +9,15 @@ module.exports = { overrides: [ { files: ['*.ts'], - plugins: ['@typescript-eslint'], + plugins: ['@typescript-eslint', "unused-imports"], extends: ['plugin:@typescript-eslint/recommended'], rules: { - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" } + ] }, }, ], diff --git a/src/papyrus-lang-vscode/.gitignore b/src/papyrus-lang-vscode/.gitignore index d92c261..6465268 100644 --- a/src/papyrus-lang-vscode/.gitignore +++ b/src/papyrus-lang-vscode/.gitignore @@ -1,5 +1,6 @@ debug-bin debug-plugin +debug-address-library pyro out node_modules diff --git a/src/papyrus-lang-vscode/package-lock.json b/src/papyrus-lang-vscode/package-lock.json index 208e238..09b6dfa 100644 --- a/src/papyrus-lang-vscode/package-lock.json +++ b/src/papyrus-lang-vscode/package-lock.json @@ -9,8 +9,12 @@ "version": "3.0.0", "dependencies": { "@semantic-release/exec": "^6.0.3", + "@terascope/fetch-github-release": "^0.8.7", + "@tybys/windows-file-version-info": "^1.0.5", "@types/semantic-release": "^17.2.4", + "binary-parser": "^2.2.1", "deepmerge": "^4.2.2", + "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "ini": "^3.0.1", "inversify": "^6.0.1", @@ -41,6 +45,7 @@ "eslint": "^8.50.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-unused-imports": "^3.0.0", "fork-ts-checker-webpack-plugin": "^7.2.14", "prettier": "^3.0.3", "rimraf": "^3.0.2", @@ -743,6 +748,82 @@ "semantic-release": ">=18.0.0-beta.1" } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@terascope/fetch-github-release": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/@terascope/fetch-github-release/-/fetch-github-release-0.8.7.tgz", + "integrity": "sha512-m6vpKCUlBYhxlx6BXQoOi0hg/FFZ5Bo/De+MjhDuoFLhR8pMzJxS0Nge8bSIXc1G8Jqmw4g4mkoZOqEb9TmMbQ==", + "dependencies": { + "extract-zip": "^2.0.1", + "gauge": "^3.0.0", + "got": "^11.4.0", + "multi-progress": "^4.0.0", + "progress": "^2.0.3", + "yargs": "^17.2.1" + }, + "bin": { + "fetch-github-release": "bin/fetch-github-release" + } + }, + "node_modules/@terascope/fetch-github-release/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@terascope/fetch-github-release/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@terascope/fetch-github-release/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -776,6 +857,23 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "node_modules/@tybys/windows-file-version-info": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tybys/windows-file-version-info/-/windows-file-version-info-1.0.5.tgz", + "integrity": "sha512-a5s4m8fFCf/bp+KcawwPTxk1ptftmWWAvsIxorI/K92DgXcCtqIvhW3z7WzXMl4E0yep+WoHTfIz4tnJldjnhg==", + "hasInstallScript": true + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/eslint": { "version": "8.4.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.10.tgz", @@ -802,6 +900,11 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", "dev": true }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz", + "integrity": "sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw==" + }, "node_modules/@types/ini": { "version": "1.3.31", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-1.3.31.tgz", @@ -814,6 +917,14 @@ "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -840,6 +951,14 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "node_modules/@types/responselike": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.1.tgz", + "integrity": "sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -871,6 +990,15 @@ "integrity": "sha512-SDatEMEtQ1cJK3esIdH6colduWBP+42Xw9Guq1sf/N6rM3ZxgljBduvZOwBsxRps/k5+Wwf5HJun6pH8OnD2gg==", "dev": true }, + "node_modules/@types/yauzl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz", + "integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.7.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.3.tgz", @@ -1469,6 +1597,11 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1565,6 +1698,14 @@ "node": ">=8" } }, + "node_modules/binary-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/binary-parser/-/binary-parser-2.2.1.tgz", + "integrity": "sha512-5ATpz/uPDgq5GgEDxTB4ouXCde7q2lqAQlSdBRQVl/AJnxmQmhIfyxJx+0MGu//D5rHQifkfGbWWlaysG0o9NA==", + "engines": { + "node": ">=12" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -1692,7 +1833,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "engines": { "node": "*" } @@ -1736,6 +1876,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -1973,6 +2152,25 @@ "node": ">=6" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -1986,6 +2184,14 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", @@ -2012,6 +2218,11 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "node_modules/conventional-changelog-angular": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz", @@ -2263,8 +2474,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -2441,6 +2650,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -2624,8 +2841,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", - "dev": true, - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -2805,6 +3020,36 @@ } } }, + "node_modules/eslint-plugin-unused-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.0.0.tgz", + "integrity": "sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw==", + "dev": true, + "dependencies": { + "eslint-rule-composer": "^0.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^6.0.0", + "eslint": "^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-rule-composer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", + "integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -3149,6 +3394,39 @@ "node": ">=6" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3208,7 +3486,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, "dependencies": { "pend": "~1.2.0" } @@ -3504,6 +3781,25 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3656,6 +3952,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -3726,6 +4046,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "node_modules/hook-std": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-2.0.0.tgz", @@ -3764,6 +4089,11 @@ "entities": "^4.3.0" } }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, "node_modules/http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -3778,6 +4108,29 @@ "node": ">= 6" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/http2-wrapper/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4237,8 +4590,7 @@ "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" }, "node_modules/json-parse-better-errors": { "version": "1.0.2", @@ -4317,7 +4669,6 @@ "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", - "dev": true, "dependencies": { "json-buffer": "3.0.1" } @@ -4467,6 +4818,14 @@ "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==" }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -4695,8 +5054,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "optional": true, "engines": { "node": ">=10" }, @@ -4776,6 +5133,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/multi-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multi-progress/-/multi-progress-4.0.0.tgz", + "integrity": "sha512-9zcjyOou3FFCKPXsmkbC3ethv51SFPoA4dJD6TscIp2pUmy26kBDZW6h9XofPELrzseSkuD7r0V+emGEeo39Pg==", + "peerDependencies": { + "progress": "^2.0.0" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -7276,6 +7641,14 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -7342,6 +7715,14 @@ "node": ">= 0.8.0" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "engines": { + "node": ">=8" + } + }, "node_modules/p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -7558,8 +7939,7 @@ "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" }, "node_modules/picocolors": { "version": "1.0.0", @@ -7704,6 +8084,14 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ps-list": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/ps-list/-/ps-list-6.3.0.tgz", @@ -7716,8 +8104,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -8024,6 +8410,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -8044,6 +8435,17 @@ "node": ">=8" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -9453,6 +9855,14 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wildcard": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", @@ -9611,7 +10021,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" @@ -10167,6 +10576,63 @@ "read-pkg-up": "^7.0.0" } }, + "@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" + }, + "@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "requires": { + "defer-to-connect": "^2.0.0" + } + }, + "@terascope/fetch-github-release": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/@terascope/fetch-github-release/-/fetch-github-release-0.8.7.tgz", + "integrity": "sha512-m6vpKCUlBYhxlx6BXQoOi0hg/FFZ5Bo/De+MjhDuoFLhR8pMzJxS0Nge8bSIXc1G8Jqmw4g4mkoZOqEb9TmMbQ==", + "requires": { + "extract-zip": "^2.0.1", + "gauge": "^3.0.0", + "got": "^11.4.0", + "multi-progress": "^4.0.0", + "progress": "^2.0.3", + "yargs": "^17.2.1" + }, + "dependencies": { + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + } + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -10197,6 +10663,22 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "@tybys/windows-file-version-info": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tybys/windows-file-version-info/-/windows-file-version-info-1.0.5.tgz", + "integrity": "sha512-a5s4m8fFCf/bp+KcawwPTxk1ptftmWWAvsIxorI/K92DgXcCtqIvhW3z7WzXMl4E0yep+WoHTfIz4tnJldjnhg==" + }, + "@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "requires": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "@types/eslint": { "version": "8.4.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.10.tgz", @@ -10223,6 +10705,11 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", "dev": true }, + "@types/http-cache-semantics": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz", + "integrity": "sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw==" + }, "@types/ini": { "version": "1.3.31", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-1.3.31.tgz", @@ -10235,6 +10722,14 @@ "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, + "@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "requires": { + "@types/node": "*" + } + }, "@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -10261,6 +10756,14 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "@types/responselike": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.1.tgz", + "integrity": "sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==", + "requires": { + "@types/node": "*" + } + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -10292,6 +10795,15 @@ "integrity": "sha512-SDatEMEtQ1cJK3esIdH6colduWBP+42Xw9Guq1sf/N6rM3ZxgljBduvZOwBsxRps/k5+Wwf5HJun6pH8OnD2gg==", "dev": true }, + "@types/yauzl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz", + "integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==", + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "6.7.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.3.tgz", @@ -10723,6 +11235,11 @@ "picomatch": "^2.0.4" } }, + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -10804,6 +11321,11 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "binary-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/binary-parser/-/binary-parser-2.2.1.tgz", + "integrity": "sha512-5ATpz/uPDgq5GgEDxTB4ouXCde7q2lqAQlSdBRQVl/AJnxmQmhIfyxJx+0MGu//D5rHQifkfGbWWlaysG0o9NA==" + }, "bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -10893,8 +11415,7 @@ "buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" }, "buffer-from": { "version": "1.1.2", @@ -10923,6 +11444,35 @@ "run-applescript": "^5.0.0" } }, + "cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==" + }, + "cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + } + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -11091,6 +11641,21 @@ "shallow-clone": "^3.0.0" } }, + "clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "requires": { + "mimic-response": "^1.0.0" + }, + "dependencies": { + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" + } + } + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -11104,6 +11669,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" + }, "colorette": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", @@ -11130,6 +11700,11 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "conventional-changelog-angular": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz", @@ -11315,8 +11890,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "optional": true, "requires": { "mimic-response": "^3.1.0" } @@ -11426,6 +11999,11 @@ "untildify": "^4.0.0" } }, + "defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==" + }, "define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -11560,8 +12138,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", - "dev": true, - "optional": true, "requires": { "once": "^1.4.0" } @@ -11809,6 +12385,21 @@ "synckit": "^0.8.5" } }, + "eslint-plugin-unused-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.0.0.tgz", + "integrity": "sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw==", + "dev": true, + "requires": { + "eslint-rule-composer": "^0.3.0" + } + }, + "eslint-rule-composer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", + "integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==", + "dev": true + }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -11916,6 +12507,27 @@ "dev": true, "optional": true }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + } + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11969,7 +12581,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, "requires": { "pend": "~1.2.0" } @@ -12183,6 +12794,22 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + } + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -12300,6 +12927,24 @@ "slash": "^3.0.0" } }, + "got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "requires": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + } + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -12347,6 +12992,11 @@ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", "dev": true }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "hook-std": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-2.0.0.tgz", @@ -12372,6 +13022,11 @@ "entities": "^4.3.0" } }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, "http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -12383,6 +13038,22 @@ "debug": "4" } }, + "http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "dependencies": { + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" + } + } + }, "https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -12687,8 +13358,7 @@ "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" }, "json-parse-better-errors": { "version": "1.0.2", @@ -12755,7 +13425,6 @@ "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", - "dev": true, "requires": { "json-buffer": "3.0.1" } @@ -12885,6 +13554,11 @@ "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==" }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -13043,9 +13717,7 @@ "mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "optional": true + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" }, "min-indent": { "version": "1.0.1", @@ -13101,6 +13773,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "multi-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multi-progress/-/multi-progress-4.0.0.tgz", + "integrity": "sha512-9zcjyOou3FFCKPXsmkbC3ethv51SFPoA4dJD6TscIp2pUmy26kBDZW6h9XofPELrzseSkuD7r0V+emGEeo39Pg==", + "requires": {} + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -14792,6 +15470,11 @@ "boolbase": "^1.0.0" } }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -14840,6 +15523,11 @@ "type-check": "^0.4.0" } }, + "p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" + }, "p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -14994,8 +15682,7 @@ "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" }, "picocolors": { "version": "1.0.0", @@ -15100,6 +15787,11 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" + }, "ps-list": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/ps-list/-/ps-list-6.3.0.tgz", @@ -15109,8 +15801,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "optional": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -15339,6 +16029,11 @@ "supports-preserve-symlinks-flag": "^1.0.0" } }, + "resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, "resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -15353,6 +16048,14 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" }, + "responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "requires": { + "lowercase-keys": "^2.0.0" + } + }, "retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -16361,6 +17064,14 @@ "isexe": "^2.0.0" } }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "wildcard": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", @@ -16482,7 +17193,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, "requires": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" diff --git a/src/papyrus-lang-vscode/package.json b/src/papyrus-lang-vscode/package.json index 932caf4..9376bed 100644 --- a/src/papyrus-lang-vscode/package.json +++ b/src/papyrus-lang-vscode/package.json @@ -62,50 +62,85 @@ }, "projectPath": { "type": "string" + }, + "launchType": { + "type": "string", + "enum": [ + "XSE", + "MO2" + ] + }, + "launcherPath": { + "type": "string" + }, + "mo2Config": { + "type": "object", + "properties": { + "shortcutURI": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "instanceINIPath": { + "type": "array" + } + }, + "required": [ + "shortcutURI" + ], + "additionalProperties": false + }, + "args": { + "type": "array" + }, + "ignoreConfigChecks": { + "type": "boolean" } }, "required": [ - "game" + "game", + "launchType", + "launcherPath" ] } }, "configurationSnippets": [ { - "label": "Papyrus: Fallout 4", + "label": "Papyrus: Attach", "body": { - "name": "Fallout 4", + "name": "Attach (${2:Fallout 4})", "type": "papyrus", + "game": "${3:fallout4}", "request": "attach", - "game": "fallout4" + "projectPath": "^\"\\${workspaceFolder}/${1:fallout4.ppj}\"" } }, { - "label": "Papyrus: Fallout 4 (with .ppj)", + "label": "Papyrus: Launch SKSE/F4SE Loader)", "body": { - "name": "Fallout 4 Project", + "name": "Launch with ${4:F4SE}_loader (${2:Fallout 4})", "type": "papyrus", - "request": "attach", - "game": "fallout4", - "projectPath": "^\"\\${workspaceFolder}/${1:Project.ppj}\"" + "game": "${3:fallout4}", + "request": "launch", + "projectPath": "^\"\\${workspaceFolder}/${1:fallout4.ppj}\"", + "launchType": "XSE", + "launcherPath": "^\"${5:C:/Program Files (x86)/Steam/steamapps/common/Fallout 4/F4SE/F4SE_loader.exe}\"" } }, { - "label": "Papyrus: Skyrim Special Edition/Anniversary Edition", + "label": "Papyrus: Launch (ModOrganizer2 Loader)", "body": { - "name": "Skyrim", + "name": "Launch with ModOrganizer2 (${2:Fallout 4})", "type": "papyrus", - "request": "attach", - "game": "skyrimSpecialEdition" - } - }, - { - "label": "Papyrus: Skyrim Special Edition/Anniversary Edition (with .ppj)", - "body": { - "name": "Skyrim Special Edition/Anniversary Edition Project", - "type": "papyrus", - "request": "attach", - "game": "skyrimSpecialEdition", - "projectPath": "^\"\\${workspaceFolder}/${1:Project.ppj}\"" + "game": "${3:fallout4}", + "request": "launch", + "launchType": "MO2", + "projectPath": "^\"\\${workspaceFolder}/${1:fallout4.ppj}\"", + "launcherPath": "^\"${6:C:/Modding/MO2/ModOrganizer.exe}\"", + "mo2Config": { + "shortcutURI": "^\"${7:moshortcut://Skyrim Special Edition:SKSE}\"" + } } } ] @@ -514,8 +549,12 @@ ], "dependencies": { "@semantic-release/exec": "^6.0.3", + "@terascope/fetch-github-release": "^0.8.7", + "@tybys/windows-file-version-info": "^1.0.5", "@types/semantic-release": "^17.2.4", + "binary-parser": "^2.2.1", "deepmerge": "^4.2.2", + "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "ini": "^3.0.1", "inversify": "^6.0.1", @@ -547,6 +586,7 @@ "eslint": "^8.50.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-unused-imports": "^3.0.0", "fork-ts-checker-webpack-plugin": "^7.2.14", "prettier": "^3.0.3", "rimraf": "^3.0.2", diff --git a/src/papyrus-lang-vscode/src/CreationKitInfoProvider.ts b/src/papyrus-lang-vscode/src/CreationKitInfoProvider.ts index 0fd35a5..98b8fb2 100644 --- a/src/papyrus-lang-vscode/src/CreationKitInfoProvider.ts +++ b/src/papyrus-lang-vscode/src/CreationKitInfoProvider.ts @@ -1,9 +1,9 @@ import { interfaces, inject, injectable } from 'inversify'; -import { PapyrusGame, getGames } from './PapyrusGame'; +import { PapyrusGame, getGames, getDevelopmentCompilerFolderForGame } from './PapyrusGame'; import { IExtensionConfigProvider } from './ExtensionConfigProvider'; import { Observable, combineLatest } from 'rxjs'; import { map, mergeMap, shareReplay } from 'rxjs/operators'; -import { IPathResolver, getDevelopmentCompilerFolderForGame } from './common/PathResolver'; +import { IPathResolver } from './common/PathResolver'; import { inDevelopmentEnvironment } from './Utilities'; import * as path from 'path'; import * as ini from 'ini'; diff --git a/src/papyrus-lang-vscode/src/PapyrusExtension.ts b/src/papyrus-lang-vscode/src/PapyrusExtension.ts index 82eff71..cbd7a44 100644 --- a/src/papyrus-lang-vscode/src/PapyrusExtension.ts +++ b/src/papyrus-lang-vscode/src/PapyrusExtension.ts @@ -26,6 +26,10 @@ import { GenerateProjectCommand } from './features/commands/GenerateProjectComma import { showWelcome } from './features/WelcomeHandler'; import { ShowWelcomeCommand } from './features/commands/ShowWelcomeCommand'; import { Container } from 'inversify'; +import { IDebugLauncherService, DebugLauncherService } from './debugger/DebugLauncherService'; +import { IAddressLibraryInstallService, AddressLibraryInstallService } from './debugger/AddressLibInstallService'; +import { IMO2LaunchDescriptorFactory, MO2LaunchDescriptorFactory } from './debugger/MO2LaunchDescriptorFactory'; +import { IMO2ConfiguratorService, MO2ConfiguratorService } from './debugger/MO2ConfiguratorService'; class PapyrusExtension implements Disposable { private readonly _serviceContainer: Container; @@ -66,6 +70,10 @@ class PapyrusExtension implements Disposable { this._serviceContainer.bind(ICreationKitInfoProvider).to(CreationKitInfoProvider); this._serviceContainer.bind(ILanguageClientManager).to(LanguageClientManager); this._serviceContainer.bind(IDebugSupportInstallService).to(DebugSupportInstallService); + this._serviceContainer.bind(IDebugLauncherService).to(DebugLauncherService); + this._serviceContainer.bind(IAddressLibraryInstallService).to(AddressLibraryInstallService); + this._serviceContainer.bind(IMO2LaunchDescriptorFactory).to(MO2LaunchDescriptorFactory); + this._serviceContainer.bind(IMO2ConfiguratorService).to(MO2ConfiguratorService); this._configProvider = this._serviceContainer.get(IExtensionConfigProvider); this._clientManager = this._serviceContainer.get(ILanguageClientManager); @@ -78,7 +86,7 @@ class PapyrusExtension implements Disposable { this._debugAdapterDescriptorFactory = this._serviceContainer.resolve(PapyrusDebugAdapterDescriptorFactory); this._installDebugSupportCommand = this._serviceContainer.resolve(InstallDebugSupportCommand); - this._debugAdapterTrackerFactory = new PapyrusDebugAdapterTrackerFactory(); + this._debugAdapterTrackerFactory = this._serviceContainer.resolve(PapyrusDebugAdapterTrackerFactory); this._attachCommand = new AttachDebuggerCommand(); diff --git a/src/papyrus-lang-vscode/src/PapyrusGame.ts b/src/papyrus-lang-vscode/src/PapyrusGame.ts index b6d5c53..7395bb7 100644 --- a/src/papyrus-lang-vscode/src/PapyrusGame.ts +++ b/src/papyrus-lang-vscode/src/PapyrusGame.ts @@ -1,20 +1,8 @@ -import * as fs from 'fs'; -import { promisify } from 'util'; - -import { xml2js } from 'xml-js'; - -import { workspace, Uri, RelativePattern } from 'vscode'; - -import { PyroGameToPapyrusGame } from './features/PyroTaskDefinition'; - -const readFile = promisify(fs.readFile); - export enum PapyrusGame { fallout4 = 'fallout4', skyrim = 'skyrim', skyrimSpecialEdition = 'skyrimSpecialEdition', } - const displayNames = new Map([ [PapyrusGame.fallout4, 'Fallout 4'], [PapyrusGame.skyrim, 'Skyrim'], @@ -39,7 +27,14 @@ const scriptExtenderNames = new Map([ export function getScriptExtenderName(game: PapyrusGame) { return scriptExtenderNames.get(game); } +const scriptExtenderExecutableNames = new Map([ + [PapyrusGame.fallout4, 'f4se_loader.exe'], + [PapyrusGame.skyrimSpecialEdition, 'skse64_loader.exe'], +]); +export function getScriptExtenderExecutableName(game: PapyrusGame) { + return scriptExtenderExecutableNames.get(game); +} const scriptExtenderUrls = new Map([ [PapyrusGame.fallout4, 'https://f4se.silverlock.org/'], [PapyrusGame.skyrimSpecialEdition, 'https://skse.silverlock.org/'], @@ -57,40 +52,72 @@ export function getGames(): PapyrusGame[] { return (Object.keys(PapyrusGame) as (keyof typeof PapyrusGame)[]).map((k) => PapyrusGame[k]); } -export async function getWorkspaceGameFromProjects(ppjFiles: Uri[]): Promise { - let game: string | undefined = undefined; - if (!ppjFiles) { - return undefined; +export function getRegistryKeyForGame(game: PapyrusGame) { + switch (game) { + case PapyrusGame.fallout4: + return 'Fallout4'; + case PapyrusGame.skyrim: + return 'Skyrim'; + case PapyrusGame.skyrimSpecialEdition: + return 'Skyrim Special Edition'; } +} - for (const ppjFile of ppjFiles) { - game = await getWorkspaceGameFromProjectFile(ppjFile.fsPath); - if (game) { - break; - } +export function getDevelopmentCompilerFolderForGame(game: PapyrusGame) { + switch (game) { + case PapyrusGame.fallout4: + return 'fallout4'; + case PapyrusGame.skyrim: + return 'does-not-exist'; + case PapyrusGame.skyrimSpecialEdition: + return 'skyrim'; } +} - if (!game || !PyroGameToPapyrusGame[game as keyof typeof PyroGameToPapyrusGame]) { - return undefined; - } +export function getDefaultFlagsFileNameForGame(game: PapyrusGame) { + return game === PapyrusGame.fallout4 ? 'Institute_Papyrus_Flags.flg' : 'TESV_Papyrus_Flags.flg'; +} - return PyroGameToPapyrusGame[game as keyof typeof PyroGameToPapyrusGame] as unknown as PapyrusGame; +const executableNames = new Map([ + [PapyrusGame.skyrim, 'Skyrim.exe'], + [PapyrusGame.fallout4, 'Fallout4.exe'], + [PapyrusGame.skyrimSpecialEdition, 'SkyrimSE.exe'], +]); + +export function getExecutableNameForGame(game: PapyrusGame) { + return executableNames.get(game)!; } -export async function getWorkspaceGameFromProjectFile(projectFile: string): Promise { - const xml = await readFile(projectFile, { encoding: 'utf-8' }); - // TODO: Annoying type cast here: - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const results = xml2js(xml, { compact: true, trim: true }) as Record; +export function getGameIniName(game: PapyrusGame): string { + return game == PapyrusGame.fallout4 ? 'fallout4.ini' : 'skyrim.ini'; +} - return results['PapyrusProject']['_attributes']['Game']; +// TODO: Support VR +export enum GameVariant { + Steam = 'Steam', + GOG = 'GOG', + Epic = 'Epic Games', } -export async function getWorkspaceGame(): Promise { - if (!workspace.workspaceFolders) { - return undefined; +/** + * returns the name of the Game Save folder for the given variant + * @param variant + * @returns + */ +export function GetUserGameFolderName(game: PapyrusGame, variant: GameVariant) { + switch (game) { + case PapyrusGame.fallout4: + return 'Fallout4'; + case PapyrusGame.skyrim: + return 'Skyrim'; + case PapyrusGame.skyrimSpecialEdition: + switch (variant) { + case GameVariant.Steam: + return 'Skyrim Special Edition'; + case GameVariant.GOG: + return 'Skyrim Special Edition GOG'; + case GameVariant.Epic: + return 'Skyrim Special Edition EPIC'; + } } - - const ppjFiles: Uri[] = await workspace.findFiles(new RelativePattern(workspace.workspaceFolders[0], '**/*.ppj')); - return getWorkspaceGameFromProjects(ppjFiles); } diff --git a/src/papyrus-lang-vscode/src/Utilities.ts b/src/papyrus-lang-vscode/src/Utilities.ts index 5127877..62ff14b 100644 --- a/src/papyrus-lang-vscode/src/Utilities.ts +++ b/src/papyrus-lang-vscode/src/Utilities.ts @@ -1,20 +1,22 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as crypto from 'crypto'; import { promisify } from 'util'; import procList from 'ps-list'; -import { CancellationTokenSource } from 'vscode'; -import { PapyrusGame } from './PapyrusGame'; -import { getExecutableNameForGame } from './common/PathResolver'; +import { getExecutableNameForGame, PapyrusGame } from './PapyrusGame'; import { isNativeError } from 'util/types'; import { getSystemErrorMap } from 'util'; - +import { execFile as _execFile } from 'child_process'; +const execFile = promisify(_execFile); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); const exists = promisify(fs.exists); +const readdir = promisify(fs.readdir); +const stat = promisify(fs.stat); export function* flatten(arrs: T[][]): IterableIterator { for (const arr of arrs) { @@ -33,14 +35,47 @@ export async function getGameIsRunning(game: PapyrusGame) { return processList.some((p) => p.name.toLowerCase() === getExecutableNameForGame(game).toLowerCase()); } -export async function waitWhile( - func: () => Promise, - cancellationToken = new CancellationTokenSource().token, - pollingFrequencyMs = 1000 -) { - while ((await func()) && !cancellationToken.isCancellationRequested) { - await delayAsync(pollingFrequencyMs); +export async function getGamePIDs(game: PapyrusGame): Promise> { + const processList = await procList(); + + const gameProcesses = processList.filter( + (p) => p.name.toLowerCase() === getExecutableNameForGame(game).toLowerCase() + ); + + if (gameProcesses.length === 0) { + return []; + } + + return gameProcesses.map((p) => p.pid); +} + +export async function getPIDforProcessName(processName: string): Promise> { + const processList = await procList(); + + const gameProcesses = processList.filter((p) => p.name.toLowerCase() === processName.toLowerCase()); + + if (gameProcesses.length === 0) { + return []; + } + + return gameProcesses.map((p) => p.pid); +} +export async function getPathFromProcess(pid: number) { + const pwsh_cmd = `(Get-Process -id ${pid}).Path`; + + const { stdout, stderr } = await execFile('powershell', [pwsh_cmd]); + if (stderr) { + return undefined; } + return stdout; +} + +export async function getPIDsforFullPath(processPath: string): Promise> { + const pidsList = await getPIDforProcessName(path.basename(processPath)); + const pids = pidsList.filter(async (pid) => { + return processPath === (await getPathFromProcess(pid)); + }); + return pids; } export function inDevelopmentEnvironment() { @@ -128,3 +163,88 @@ export async function copyAndFillTemplate(srcPath: string, dstPath: string, valu } return writeFile(dstPath, templStr); } + +export interface EnvData { + [key: string]: string; +} + +export async function getEnvFromProcess(pid: number) { + const pwsh_cmd = `(Get-Process -id ${pid}).StartInfo.EnvironmentVariables.ForEach( { $_.Key + "=" + $_.Value } )`; + + const { stdout, stderr } = await execFile('powershell', [pwsh_cmd]); + if (stderr) { + return undefined; + } + const otherEnv: EnvData = {}; + stdout.split('\r\n').forEach((line) => { + const [key, value] = line.split('='); + if (key && key !== '') { + otherEnv[key] = value; + } + }); + return otherEnv; +} + +export async function CheckHash(data: Buffer, expectedHash: string) { + const hash = crypto.createHash('sha256'); + hash.update(data); + const actualHash = hash.digest('hex'); + if (expectedHash !== actualHash) { + return false; + } + return true; +} + +async function _GetHashOfFolder(folderPath: string, inputHash?: crypto.Hash): Promise { + if (!inputHash) { + return undefined; + } + const info = await readdir(folderPath, { withFileTypes: true }); + if (!info || info.length == 0) { + return undefined; + } + for (const item of info) { + const fullPath = path.join(folderPath, item.name); + if (item.isFile()) { + const data = fs.readFileSync(fullPath); + inputHash.update(data); + } else if (item.isDirectory()) { + // recursively walk sub-folders + await _GetHashOfFolder(fullPath, inputHash); + } + } + return inputHash; +} + +export async function GetHashOfFolder(folderPath: string): Promise { + return (await _GetHashOfFolder(folderPath, crypto.createHash('sha256')))?.digest('hex'); +} + +export async function CheckHashOfFolder(folderPath: string, expectedSHA256: string): Promise { + const hash = await GetHashOfFolder(folderPath); + if (!hash) { + return false; + } + if (hash !== expectedSHA256) { + return false; + } + return true; +} + +export async function CheckHashFile(filePath: string, expectedSHA256: string) { + // get the hash of the file + if (!(await exists(filePath)) || !(await stat(filePath)).isFile()) { + return false; + } + const buffer = await readFile(filePath); + if (!buffer) { + return false; + } + const hash = crypto.createHash('sha256'); + hash.update(buffer); + const actualHash = hash.digest('hex'); + if (expectedSHA256 !== actualHash) { + return false; + } + return true; +} diff --git a/src/papyrus-lang-vscode/src/VsCodeUtilities.ts b/src/papyrus-lang-vscode/src/VsCodeUtilities.ts new file mode 100644 index 0000000..9894bfa --- /dev/null +++ b/src/papyrus-lang-vscode/src/VsCodeUtilities.ts @@ -0,0 +1,12 @@ +import { CancellationTokenSource } from 'vscode'; +import { delayAsync } from './Utilities'; + +export async function waitWhile( + func: () => Promise, + cancellationToken = new CancellationTokenSource().token, + pollingFrequencyMs = 1000 +) { + while ((await func()) && !cancellationToken.isCancellationRequested) { + await delayAsync(pollingFrequencyMs); + } +} diff --git a/src/papyrus-lang-vscode/src/WorkspaceGame.ts b/src/papyrus-lang-vscode/src/WorkspaceGame.ts new file mode 100644 index 0000000..0aed908 --- /dev/null +++ b/src/papyrus-lang-vscode/src/WorkspaceGame.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +import { promisify } from 'util'; + +import { xml2js } from 'xml-js'; + +import { workspace, Uri, RelativePattern } from 'vscode'; + +import { PyroGameToPapyrusGame } from './features/PyroTaskDefinition'; +import { PapyrusGame } from './PapyrusGame'; + +const readFile = promisify(fs.readFile); + +export async function getWorkspaceGameFromProjects(ppjFiles: Uri[]): Promise { + let game: string | undefined = undefined; + if (!ppjFiles) { + return undefined; + } + + for (const ppjFile of ppjFiles) { + game = await getWorkspaceGameFromProjectFile(ppjFile.fsPath); + if (game) { + break; + } + } + + if (!game || !PyroGameToPapyrusGame[game as keyof typeof PyroGameToPapyrusGame]) { + return undefined; + } + + return PyroGameToPapyrusGame[game as keyof typeof PyroGameToPapyrusGame] as unknown as PapyrusGame; +} + +export async function getWorkspaceGameFromProjectFile(projectFile: string): Promise { + const xml = await readFile(projectFile, { encoding: 'utf-8' }); + // TODO: Annoying type cast here: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const results = xml2js(xml, { compact: true, trim: true }) as Record; + + return results['PapyrusProject']['_attributes']['Game']; +} + +export async function getWorkspaceGame(): Promise { + if (!workspace.workspaceFolders) { + return undefined; + } + + const ppjFiles: Uri[] = await workspace.findFiles(new RelativePattern(workspace.workspaceFolders[0], '**/*.ppj')); + return getWorkspaceGameFromProjects(ppjFiles); +} diff --git a/src/papyrus-lang-vscode/src/common/GameHelpers.ts b/src/papyrus-lang-vscode/src/common/GameHelpers.ts new file mode 100644 index 0000000..4c55bae --- /dev/null +++ b/src/papyrus-lang-vscode/src/common/GameHelpers.ts @@ -0,0 +1,210 @@ +import path from 'path'; +import { + getRegistryKeyForGame, + PapyrusGame, + GameVariant, + GetUserGameFolderName, + getScriptExtenderName, +} from '../PapyrusGame'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import { + AddressLibAssetSuffix, + AddressLibraryF4SEModName, + AddressLibraryName, + AddressLibrarySKSEAEModName, + AddressLibrarySKSEModName, +} from './constants'; +import { INIData } from './INIHelpers'; +import { getHomeFolder, getRegistryValueData } from './OSHelpers'; + +const exists = promisify(fs.exists); +const readdir = promisify(fs.readdir); +const readFile = promisify(fs.readFile); + +export function getAsssetLibraryDLSuffix(addlibname: AddressLibraryName): AddressLibAssetSuffix { + switch (addlibname) { + case AddressLibrarySKSEModName: + return AddressLibAssetSuffix.SkyrimSE; + case AddressLibrarySKSEAEModName: + return AddressLibAssetSuffix.SkyrimAE; + case AddressLibraryF4SEModName: + return AddressLibAssetSuffix.Fallout4; + } +} + +export function getAddressLibNameFromAssetSuffix(suffix: AddressLibAssetSuffix): AddressLibraryName { + switch (suffix) { + case AddressLibAssetSuffix.SkyrimSE: + return AddressLibrarySKSEModName; + case AddressLibAssetSuffix.SkyrimAE: + return AddressLibrarySKSEAEModName; + case AddressLibAssetSuffix.Fallout4: + return AddressLibraryF4SEModName; + } +} + +export function getAddressLibNames(game: PapyrusGame): AddressLibraryName[] { + if (game === PapyrusGame.fallout4) { + return [AddressLibraryF4SEModName]; + } else if (game === PapyrusGame.skyrimSpecialEdition) { + return [AddressLibrarySKSEModName, AddressLibrarySKSEAEModName]; + } + // there is no skyrim classic address library + return []; +} + +export function CheckIfDebuggingIsEnabledInIni(iniData: INIData) { + return ( + iniData.Papyrus.bLoadDebugInformation === 1 && + iniData.Papyrus.bEnableTrace === 1 && + iniData.Papyrus.bEnableLogging === 1 + ); +} + +export function TurnOnDebuggingInIni(skyrimIni: INIData) { + const _ini = structuredClone(skyrimIni); + _ini.Papyrus.bLoadDebugInformation = 1; + _ini.Papyrus.bEnableTrace = 1; + _ini.Papyrus.bEnableLogging = 1; + return _ini; +} + +export async function FindUserGamePath(game: PapyrusGame, variant: GameVariant): Promise { + const GameFolderName: string = GetUserGameFolderName(game, variant); + const home = getHomeFolder(); + if (!home) { + return null; + } + const userGamePath = path.join(home, 'Documents', 'My Games', GameFolderName); + if (await exists(userGamePath)) { + return userGamePath; + } + return null; +} + +/** + * We need to determine variants for things like the save game path + * @param game + * @param installPath + * @returns + */ +export async function DetermineGameVariant(game: PapyrusGame, installPath: string): Promise { + // only Skyrim SE has variants, the rest are only sold on steam + if (game !== PapyrusGame.skyrimSpecialEdition) { + return GameVariant.Steam; + } + if (!installPath || !(await exists(installPath))) { + // just default to steam + return GameVariant.Steam; + } + const gog_dll = path.join(installPath, 'Galaxy64.dll'); + const epic_dll = path.join(installPath, 'EOSSDK-Win64-Shipping.dll'); + if (await exists(gog_dll)) { + return GameVariant.GOG; + } + if (await exists(epic_dll)) { + return GameVariant.Epic; + } + // default to steam + return GameVariant.Steam; +} + +async function findSkyrimSEEpic(): Promise { + const key = `\\SOFTWARE\\${process.arch === 'x64' ? 'WOW6432Node\\' : ''}Epic Games\\EpicGamesLauncher`; + const val = 'AppDataPath'; + const epicAppdatapath = await getRegistryValueData(key, val); + let manifestsDir: string; + if (epicAppdatapath) { + manifestsDir = path.join(epicAppdatapath, 'Manifests'); + } else if (process.env.PROGRAMDATA) { + // if the local app data path isn't set, try the global one + manifestsDir = path.join(process.env.PROGRAMDATA, 'Epic', 'EpicGamesLauncher', 'Data', 'Manifests'); + } else { + return null; + } + if (await exists(manifestsDir)) { + // list the directory and find the manifest for Skyrim SE + const manifestFiles = await readdir(manifestsDir); + for (const manifestFile of manifestFiles) { + // read the manifest file and check if it's for Skyrim SE + if (path.extname(manifestFile) !== '.item') { + continue; + } + const data = await readFile(path.join(manifestsDir, manifestFile), 'utf8'); + if (data) { + const manifest = JSON.parse(data); + if ( + manifest && + manifest.AppName && + (manifest.AppName === 'ac82db5035584c7f8a2c548d98c86b2c' || + manifest.AppName === '5d600e4f59974aeba0259c7734134e27') + ) { + if (manifest.InstallLocation && (await exists(manifest.InstallLocation))) { + return manifest.InstallLocation; + } + } + } + } + } + return null; +} + +async function findSkyrimSEGOG(): Promise { + const keynames = [ + // check Skyrim AE first + `\\SOFTWARE\\${process.arch === 'x64' ? 'WOW6432Node\\' : ''}GOG.com\\Games\\1162721350`, + // If AE isn't installed, check Skyrim SE + `\\SOFTWARE\\${process.arch === 'x64' ? 'WOW6432Node\\' : ''}GOG.com\\Games\\1711230643`, + ]; + for (const key of keynames) { + const gogpath = await getRegistryValueData(key, 'path'); + if (gogpath && (await exists(gogpath))) { + return gogpath; + } + } + return null; +} + +async function FindGameSteamPath(game: PapyrusGame): Promise { + const key = `\\SOFTWARE\\${ + process.arch === 'x64' ? 'WOW6432Node\\' : '' + }Bethesda Softworks\\${getRegistryKeyForGame(game)}`; + const val = 'installed path'; + const pathValue = await getRegistryValueData(key, val); + if (pathValue && (await exists(pathValue))) { + return pathValue; + } + return null; +} + +export async function FindGamePath(game: PapyrusGame) { + if (game === PapyrusGame.fallout4 || game === PapyrusGame.skyrim) { + return FindGameSteamPath(game); + } else if (game === PapyrusGame.skyrimSpecialEdition) { + let path = await FindGameSteamPath(game); + if (path) { + return path; + } + path = await findSkyrimSEGOG(); + if (path) { + return path; + } + path = await findSkyrimSEEpic(); + if (path) { + return path; + } + } + return null; +} + +/** + * Will return the path to the plugins folder for the given game relative to the game's data folder + * + * + * @param game fallout4 or skyrimse + * @returns + */ +export function getRelativePluginPath(game: PapyrusGame) { + return `${getScriptExtenderName(game)}/Plugins`; +} diff --git a/src/papyrus-lang-vscode/src/common/GithubHelpers.ts b/src/papyrus-lang-vscode/src/common/GithubHelpers.ts new file mode 100644 index 0000000..988d0dd --- /dev/null +++ b/src/papyrus-lang-vscode/src/common/GithubHelpers.ts @@ -0,0 +1,129 @@ +import { getReleases } from '@terascope/fetch-github-release/dist/src/getReleases'; +import { GithubRelease } from '@terascope/fetch-github-release/dist/src/interfaces'; +import { downloadRelease } from '@terascope/fetch-github-release/dist/src/downloadRelease'; +import { getLatest } from '@terascope/fetch-github-release/dist/src/getLatest'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import { CheckHashFile } from '../Utilities'; + +const exists = promisify(fs.exists); +export enum DownloadResult { + success, + repoFailure, + sha256sumDownloadFailure, + filesystemFailure, + downloadFailure, + checksumMismatch, + releaseHasMultipleMatchingAssets, + cancelled, +} + +/** + * Downloads all assets from a specific release + * @param githubUserName The name of the user or organization that owns the repo + * @param repoName The name of the repo + * @param releaseId The id of the release + * @param downloadFolder The folder to download the assets to + * @returns An array of paths to the downloaded assets + * @throws An error if the repo does not exist or the release does not exist + * @throws An error if the release has multiple assets with the same name + * @throws An error if the download fails + */ +export async function downloadAssetsFromGitHub( + githubUserName: string, + repoName: string, + releaseId: number, + downloadFolder: string +): Promise { + const paths = await downloadRelease( + githubUserName, + repoName, + downloadFolder, + (release) => release.id == releaseId, + undefined, + true + ); + if (!paths || paths.length == 0) { + return undefined; + } + return paths; +} + +/** + * Downloads a specific asset from a specific release + * @param githubUserName The name of the user or organization that owns the repo + * @param repoName The name of the repo + * @param release_id The id of the release + * @param assetFileName The file name of the asset to download + * @param downloadFolder The folder to download the asset to + * @returns The path to the downloaded asset + * @throws An error if the repo does not exist or the release does not exist + * @throws An error if the release has multiple assets with the same name + * @throws An error if the download fails + * @throws An error if the asset does not exist in the release + */ +export async function downloadAssetFromGitHub( + githubUserName: string, + repoName: string, + release_id: number, + assetFileName: string, + downloadFolder: string +): Promise { + const paths = await downloadRelease( + githubUserName, + repoName, + downloadFolder, + (release) => release.id == release_id, + (asset) => asset.name === assetFileName, + true + ); + return paths && paths.length > 0 ? paths[0] : undefined; +} + +/** + * Downloads a specific asset from a specific release and checks the hash + * @param githubUserName The name of the user or organization that owns the repo + * @param repoName The name of the repo + * @param release_id The id of the release + * @param assetFileName The file name of the asset to download + * @param downloadFolder The folder to download the asset to + * @param expectedSha256Sum The expected SHA256 hash of the file + * @returns status of the download + */ +export async function DownloadAssetAndCheckHash( + githubUserName: string, + RepoName: string, + release_id: number, + assetFileName: string, + downloadFolder: string, + expectedSha256Sum: string +): Promise { + let dlPath: string | undefined; + try { + dlPath = await downloadAssetFromGitHub(githubUserName, RepoName, release_id, assetFileName, downloadFolder); + } catch (e) { + return DownloadResult.downloadFailure; + } + if (!dlPath || !(await exists(dlPath))) { + return DownloadResult.downloadFailure; + } + if (!CheckHashFile(dlPath, expectedSha256Sum)) { + fs.rmSync(dlPath); + return DownloadResult.checksumMismatch; + } + return DownloadResult.success; +} + +export async function GetLatestReleaseFromRepo( + githubUserName: string, + repoName: string, + prerelease: boolean = false +): Promise { + // if pre-releases == false, filter out pre-releases + const releaseFilter = !prerelease ? (release: GithubRelease) => release.prerelease == false : undefined; + const latestRelease = await getLatest(await getReleases(githubUserName, repoName), releaseFilter); + if (!latestRelease) { + return undefined; + } + return latestRelease; +} diff --git a/src/papyrus-lang-vscode/src/common/INIHelpers.ts b/src/papyrus-lang-vscode/src/common/INIHelpers.ts new file mode 100644 index 0000000..d5e3e74 --- /dev/null +++ b/src/papyrus-lang-vscode/src/common/INIHelpers.ts @@ -0,0 +1,67 @@ +import * as ini from 'ini'; +import * as fs from 'fs'; +import { promisify } from 'util'; +const readFile = promisify(fs.readFile); + +export interface INIData { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export function ParseIniArray(data: INIData): INIData[] | undefined { + if (!data || data.size === undefined || data.size === null) { + return undefined; + } + const array = new Array(); + if (data.size === 0) { + return array; + } + for (let i = 0; i < data.size; i++) { + array.push({} as INIData); + } + // Keys in INI arrays are in the format of 1\{key1}, 1\{key2}, 2\{key1}, 2\{key2}, etc. + const keys = Object.keys(data); + keys.forEach((key) => { + if (key !== 'size') { + const parts = key.split('\\'); + if (parts.length === 2) { + // INI arrays are 1-indexed + const index = parseInt(parts[0], 10) - 1; + const subKey = parts[1]; + array[index][subKey] = data[key]; + } + } + }); + return array; +} + +export function SerializeIniArray(data: INIData[]): INIData { + const iniData = {} as INIData; + iniData.size = data.length; + data.forEach((value, index) => { + Object.keys(value).forEach((key) => { + iniData[`${index + 1}\\${key}`] = value[key]; + }); + }); + return iniData; +} + +export async function ParseIniFile(IniPath: string): Promise { + if (!fs.existsSync(IniPath) || !fs.lstatSync(IniPath).isFile()) { + return undefined; + } + const IniText = await readFile(IniPath, 'utf-8'); + if (!IniText) { + return undefined; + } + return ini.parse(IniText) as INIData; +} + +export async function WriteChangesToIni(gameIniPath: string, skyrimIni: INIData) { + const file = fs.openSync(gameIniPath, 'w'); + if (!file) { + return false; + } + fs.writeFileSync(file, ini.stringify(skyrimIni)); + return false; +} diff --git a/src/papyrus-lang-vscode/src/common/MO2Lib.ts b/src/papyrus-lang-vscode/src/common/MO2Lib.ts new file mode 100644 index 0000000..a93ad51 --- /dev/null +++ b/src/papyrus-lang-vscode/src/common/MO2Lib.ts @@ -0,0 +1,979 @@ +import { existsSync, openSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import * as fs from 'fs'; +import path from 'path'; +import { INIData, ParseIniArray, ParseIniFile, SerializeIniArray } from './INIHelpers'; +import { getLocalAppDataFolder, getRegistryValueData } from './OSHelpers'; + +export enum ModEnabledState { + unmanaged = '*', + enabled = '+', + disabled = '-', + unknown = '?', +} + +export class ModListItem { + public name: string = ''; + public enabled: ModEnabledState = ModEnabledState.unmanaged; + constructor(name: string, enabled: ModEnabledState) { + this.name = name; + this.enabled = enabled; + } +} + +export const InstanceIniName = 'ModOrganizer.ini'; +export const MO2EXEName = 'ModOrganizer.exe'; + +export interface MO2CustomExecutableInfo { + arguments: string; + binary: string; + hide: boolean; + ownicon: boolean; + steamAppID: string; + title: string; + toolbar: boolean; + workingDirectory: string; +} +export type MO2LongGameID = + | 'Fallout 4' + | 'Skyrim Special Edition' + | 'Skyrim' + | 'Enderal' + | 'Fallout 3' + | 'Fallout 4 VR' + | 'New Vegas' + | 'Morrowind' + | 'Skyrim VR' + | 'TTW' + | 'Other'; +export type MO2ShortGameID = 'Fallout4' | 'SkyrimSE' | 'Skyrim'; //TODO: Do the rest of these | 'enderal' | 'fo3' | 'fo4vr' | 'nv' | 'morrowind' | 'skyrimvr' | 'ttw' | 'other'; + +export interface MO2InstanceInfo { + name: string; + gameName: MO2LongGameID; + gameDirPath: string; + selectedProfile: string; + iniPath: string; + baseDirectory: string; + downloadsFolder: string; + modsFolder: string; + cachesFolder: string; + profilesFolder: string; + overwriteFolder: string; + customExecutables: MO2CustomExecutableInfo[]; +} + +// import a library that deals with nukpg version strings + +// import the semver library + +export interface WinVerObject { + major: number; + minor: number; + build: number; + privateNum: number; + version: string; +} + +export class WinVer implements WinVerObject { + public readonly major: number = 0; + public readonly minor: number = 0; + public readonly build: number = 0; + public readonly privateNum: number = 0; + public readonly version: string = '0.0.0.0'; + public static fromVersionString(version: string): WinVer { + const parts = version.split('.'); + const major = parseInt(parts[0]); + const minor = parseInt(parts[1]); + const build = parseInt(parts[2]); + const privateNum = parseInt(parts[3]); + return new WinVer({ major, minor, build, privateNum, version }); + } + public static lessThan(a: WinVerObject, b: WinVerObject): boolean { + return a.major < b.major || a.minor < b.minor || a.build < b.build || a.privateNum < b.privateNum; + } + public static equal(a: WinVerObject, b: WinVerObject): boolean { + return a.major === b.major && a.minor === b.minor && a.build === b.build && a.privateNum === b.privateNum; + } + public static greaterThan(a: WinVerObject, b: WinVerObject): boolean { + return a.major > b.major || a.minor > b.minor || a.build > b.build || a.privateNum > b.privateNum; + } + public static greaterThanOrEqual(a: WinVerObject, b: WinVerObject): boolean { + return WinVer.greaterThan(a, b) || WinVer.equal(a, b); + } + public lt(other: WinVer): boolean { + return WinVer.lessThan(this, other); + } + public eq(other: WinVer): boolean { + return WinVer.equal(this, other); + } + public gt(other: WinVer): boolean { + return WinVer.greaterThan(this, other); + } + constructor(iWinVer: WinVerObject) { + this.major = iWinVer.major; + this.minor = iWinVer.minor; + this.build = iWinVer.build; + this.privateNum = iWinVer.privateNum; + this.version = iWinVer.version; + } +} + +export interface MO2ModMetaInstalledFile { + modid: number; + fileid: number; +} + +export interface MO2ModMeta { + modid: number; + version: string; + newestVersion: string; + category: string; + installedFiles: MO2ModMetaInstalledFile[]; + nexusFileStatus?: number; + gameName?: MO2ShortGameID; + ignoredVersion?: string; + installationFile?: string; + repository?: string; + comments?: string; + notes?: string; + nexusDescription?: string; + url?: string; + hasCustomURL?: boolean; + lastNexusQuery?: string; + lastNexusUpdate?: string; + nexusLastModified?: string; + converted?: boolean; + validated?: boolean; + color?: string; + endorsed?: number; + tracked?: number; +} + +export interface MO2Location { + MO2EXEPath: string; + instances: MO2InstanceInfo[]; + isPortable: boolean; +} + +export function GetGlobalMO2DataFolder(): string | undefined { + const appdata = getLocalAppDataFolder(); + if (appdata === undefined) { + return undefined; + } + return path.join(appdata, 'ModOrganizer'); +} + +export async function IsMO2Portable(MO2EXEPath: string): Promise { + const basedir = path.dirname(MO2EXEPath); + const portableSigil = path.join(basedir, 'portable.txt'); + if (existsSync(portableSigil)) { + return true; + } + return false; +} + +export async function GetMO2EXELocations(gameId?: MO2LongGameID, ...additionalIds: MO2LongGameID[]): Promise { + let possibleLocations: string[] = []; + const nxmHandlerIniPath = await FindNXMHandlerIniPath(); + if (nxmHandlerIniPath === undefined) { + return possibleLocations; + } + const MO2NXMData = await ParseIniFile(nxmHandlerIniPath); + if (MO2NXMData === undefined) { + return possibleLocations; + } + possibleLocations = GetMO2EXELocationsFromNXMHandlerData(MO2NXMData, gameId, ...additionalIds); + // Filter all the ones that don't exist + return possibleLocations.filter((value) => fs.existsSync(value)); +} + +function getIDFromNXMHandlerName(nxmName: string): MO2LongGameID | undefined { + const _nxmName = nxmName.toLowerCase().replace(/ /g, ''); + switch (_nxmName) { + case 'skyrimse': + case 'skyrimspecialedition': + return 'Skyrim Special Edition'; + case 'skyrim': + return 'Skyrim'; + case 'fallout4': + return 'Fallout 4'; + case 'enderal': + return 'Enderal'; + case 'fallout3': + return 'Fallout 3'; + case 'fallout4vr': + return 'Fallout 4 VR'; + case 'falloutnv': + case 'newvegas': + return 'New Vegas'; + case 'morrowind': + return 'Morrowind'; + case 'skyrimvr': + return 'Skyrim VR'; + case 'ttw': + return 'TTW'; + case 'other': + return 'Other'; + default: + return undefined; + } +} + +/** + * ModOrganizer2 installs a nxmhandler.ini file in the global data folder. + * This conveniently has a list of all the MO2 installations (even the portable ones) + * and their associated game(s). + * + * @param nxmData + * @param gameID - The game ID to filter by + * @param additionalIds - Additional game IDs to filter by + */ +function GetMO2EXELocationsFromNXMHandlerData( + nxmData: INIData, + gameId?: MO2LongGameID, + ...additionalIds: MO2LongGameID[] +): string[] { + let exePaths: string[] = []; + if (!nxmData.handlers) { + return exePaths; + } + const handler_array = ParseIniArray(nxmData.handlers); + if (!handler_array || handler_array.length === 0) { + return exePaths; + } + for (const handler of handler_array) { + const executable: string | undefined = handler.executable; + const games: string | undefined = handler.games; + if (!executable || !games) { + continue; + } + const gameList = NormalizeIniString(games) + .split(',') + .filter((val) => val !== ''); + if (!gameId) { + exePaths.push(executable); + } else { + const _args = [gameId, ...additionalIds]; + for (const gameID of _args) { + if ( + gameList.filter((val) => { + const _valID = getIDFromNXMHandlerName(val); + if (_valID === undefined) { + return false; + } + return _valID === gameID; + }).length > 0 + ) { + exePaths.push(executable); + break; + } + } + } + } + + // filter out non-uniques + // We do it after the above because duplicate paths may have different `games` values + exePaths = exePaths.filter((value, index, self) => { + return self.indexOf(value) === index; + }); + return exePaths; +} +function NormalizeIniString(str: string) { + let _str = str; + if (_str.startsWith('@ByteArray(') && _str.endsWith(')')) { + _str = _str.substring(11, _str.length - 1); + } + // replace all '\"' with '"' + _str = _str.replace(/\\"/g, '"'); + // replace all '\\' with '\' + _str = _str.replace(/\\\\/g, '\\'); + return _str; +} +function NormalizeIniPathString(pathstring: string) { + let _str = NormalizeIniString(pathstring); + // remove all leading and trailing quotes + if (_str.startsWith('\\"') && _str.endsWith('\\"')) { + _str = _str.substring(2, _str.length - 2); + } + if (_str.startsWith('"') && _str.endsWith('"')) { + _str = _str.substring(1, _str.length - 1); + } + return _normalizePath(_str); +} +function NormalizeMO2IniPathString(pathstring: string, basedir: string) { + return _normalizePath(NormalizeIniPathString(pathstring)?.replace(/%BASE_DIR%/g, _normalizePath(basedir) + '/')); +} +function NormalizePath(pathstring: string): string { + return _normalizePath(pathstring) || ''; +} +function _normalizePath(pathstring: string | undefined) { + return pathstring === undefined ? undefined : path.normalize(pathstring).replace(/\\/g, '/'); +} + +function _normInistr(str: string | undefined): string | undefined { + return str === undefined ? undefined : NormalizeIniString(str); +} + +function _normIniPath(pathstring: string | undefined): string | undefined { + return pathstring === undefined ? undefined : NormalizeIniPathString(pathstring); +} + +function _normMO2Path(pathstring: string | undefined, basedir: string | undefined): string | undefined { + return pathstring === undefined || basedir === undefined + ? undefined + : NormalizeMO2IniPathString(pathstring, basedir); +} + +function ParseMO2CustomExecutable(iniData: INIData) { + const title = _normInistr(iniData.title); + const binary = _normIniPath(iniData.binary); + const steamAppID = _normInistr(iniData.steamAppID) || ''; + const toolbar = iniData.toolbar === true; // explicit boolean check in case unset + const hide = iniData.hide === true; + const ownicon = iniData.ownicon === true; + const arguments_ = _normInistr(iniData.arguments) || ''; + const workingDirectory = _normIniPath(iniData.workingDirectory) || ''; + if (title !== undefined && binary !== undefined) { + const result: MO2CustomExecutableInfo = { + arguments: arguments_, + binary: binary, + hide: hide, + ownicon: ownicon, + steamAppID: steamAppID, + title: title, + toolbar: toolbar, + workingDirectory: workingDirectory, + }; + return result; + } + return undefined; +} + +function ParseMO2CustomExecutables(iniArray: INIData[]) { + const result: MO2CustomExecutableInfo[] = []; + for (const iniData of iniArray) { + const parsed = ParseMO2CustomExecutable(iniData); + if (parsed) { + result.push(parsed); + } + } + return result; +} + +/** + * Parses the data from a ModOrganizer.ini file and returns the information needed + * + * The ini data is structured like this: + * ```none + * [General] + * gameName=Skyrim Special Edition + * selected_profile=@ByteArray(Default) + * gamePath=@ByteArray(D:\\Games\\Skyrim Special Edition) + * version=2.4.4 + * first_start=false + * <...> + * [Settings] + * <...> + * base_directory="D:\\ModsForSkyrim" + * mod_directory=D:\ModsForSkyrim\mods + * ``` + * + * gameName is the long MO2 identifier of the game, like "Skyrim Special Edition"; it's set by MO2, it's not possible to be changed by the user + * + * base_directory doesn't get set if the ModOrganizer.ini file is in the base directory + * + * the various _directory values don't get set if they do not differ from %BASE_DIR%/{value} + * + * @param iniPath - path to the ModOrganizer.ini file + * @param iniData - data from the ModOrganizer.ini file + * @returns + */ +function ParseInstanceINI(iniPath: string, iniData: INIData, isPortable: boolean): MO2InstanceInfo | undefined { + const iniBaseDir = NormalizePath(path.dirname(iniPath)); + const instanceName = isPortable ? 'portable' : path.basename(iniBaseDir); + const gameName: MO2LongGameID = iniData.General['gameName']; + if (gameName === undefined) { + return undefined; + } + const gameDirPath = _normIniPath(iniData.General['gamePath']); + if (gameDirPath === undefined) { + return undefined; + } + // TODO: We should probably pin to a specific minor version of MO2 + const _version = iniData.General['version']; + + // TODO: Figure out if this is ever not set + const selectedProfile = _normInistr(iniData.General['selected_profile']) || 'Default'; + + const settings = iniData.Settings || {}; // Settings may be empty; we don't need it to populate the rest of the information + + const baseDirectory = _normIniPath(settings['base_directory']) || iniBaseDir; + const downloadsPath = + _normMO2Path(settings['download_directory'], baseDirectory) || path.join(baseDirectory, 'downloads'); + const modsPath = _normMO2Path(settings['mod_directory'], baseDirectory) || path.join(baseDirectory, 'mods'); + const cachesPath = _normMO2Path(settings['cache_directory'], baseDirectory) || path.join(baseDirectory, 'webcache'); + const profilesPath = + _normMO2Path(settings['profiles_directory'], baseDirectory) || path.join(baseDirectory, 'profiles'); + const overwritePath = + _normMO2Path(settings['overwrite_directory'], baseDirectory) || path.join(baseDirectory, 'overwrite'); + let customExecutables: MO2CustomExecutableInfo[] = []; + if (iniData.customExecutables) { + const arr = ParseIniArray(iniData.customExecutables); + if (arr && arr.length > 0) { + customExecutables = ParseMO2CustomExecutables(arr); + } + } + return { + name: instanceName, + gameName: gameName, + gameDirPath: gameDirPath, + customExecutables: customExecutables, + selectedProfile: selectedProfile, + iniPath: iniPath, + baseDirectory: baseDirectory, + downloadsFolder: downloadsPath, + modsFolder: modsPath, + cachesFolder: cachesPath, + profilesFolder: profilesPath, + overwriteFolder: overwritePath, + }; +} + +/** + * Parses the data from a ModOrganizer.ini file and returns an MO2InstanceInfo object + * @param iniPath - path to the ModOrganizer.ini file + * @returns MO2InstanceInfo + */ +export async function GetMO2InstanceInfo(iniPath: string): Promise { + const iniData = await ParseIniFile(iniPath); + if (iniData === undefined) { + return undefined; + } + return ParseInstanceINI(iniPath, iniData, await IsMO2Portable(iniPath)); +} + +export async function validateInstanceLocationInfo(info: MO2InstanceInfo): Promise { + // check that all the directory paths exist and they are directories + const dirPaths = [ + info.gameDirPath, + info.baseDirectory, + info.downloadsFolder, + info.modsFolder, + info.cachesFolder, + info.profilesFolder, + info.overwriteFolder, + ]; + for (const p of dirPaths) { + if (!existsSync(p)) { + return false; + } + //check if p is a directory + if (!fs.statSync(p).isDirectory()) { + return false; + } + } + if (!existsSync(info.iniPath) || !fs.statSync(info.iniPath).isFile()) { + return false; + } + return true; +} + +export async function FindInstanceForEXE(MO2EXEPath: string, instanceName?: string) { + if (!fs.existsSync(MO2EXEPath)) { + return undefined; + } + const isPortable = await IsMO2Portable(MO2EXEPath); + if (isPortable) { + const instanceFolder = path.dirname(MO2EXEPath); + const instanceIniPath = path.join(instanceFolder, InstanceIniName); + return await GetMO2InstanceInfo(instanceIniPath); + } else if (instanceName !== undefined && instanceName !== 'portable') { + return await FindGlobalInstance(instanceName); + } + return undefined; +} + +function _portableExeIni(exePath: string): string { + return path.join(path.dirname(exePath), InstanceIniName); +} + +export async function GetLocationInfoForEXE( + MO2EXEPath: string, + gameId?: MO2LongGameID, + ...addtionalIds: MO2LongGameID[] +): Promise { + if (!fs.existsSync(MO2EXEPath)) { + return undefined; + } + const isPortable = await IsMO2Portable(MO2EXEPath); + let instanceInfos: MO2InstanceInfo[] = []; + if (isPortable) { + const instanceInfo = await GetMO2InstanceInfo(_portableExeIni(MO2EXEPath)); + instanceInfos = instanceInfo ? [instanceInfo] : []; + } else { + instanceInfos = await FindGlobalInstances(gameId, ...addtionalIds); + } + if (instanceInfos.length === 0) { + return undefined; + } + return { + MO2EXEPath: MO2EXEPath, + isPortable: isPortable, + instances: instanceInfos, + }; +} + +export async function FindAllKnownMO2EXEandInstanceLocations( + gameID?: MO2LongGameID, + ...additionalIds: MO2LongGameID[] +): Promise { + const possibleLocations: MO2Location[] = []; + const exeLocations = await GetMO2EXELocations(gameID, ...additionalIds); + if (exeLocations.length !== 0) { + const globalInstances = (await FindGlobalInstances(gameID)) || []; + for (const exeLocation of exeLocations) { + let instanceInfos: MO2InstanceInfo[] | undefined = undefined; + const isPortable = await IsMO2Portable(exeLocation); + if (isPortable) { + const instanceInfo = await GetMO2InstanceInfo(_portableExeIni(exeLocation)); + instanceInfos = instanceInfo ? [instanceInfo] : []; + } else { + instanceInfos = globalInstances; + } + if (instanceInfos.length === 0) { + continue; + } + possibleLocations.push({ + MO2EXEPath: exeLocation, + instances: instanceInfos, + isPortable: isPortable, + }); + } + } + return possibleLocations; +} + +function GetNXMHandlerIniPath(): string | undefined { + const global = GetGlobalMO2DataFolder(); + if (global === undefined) { + return undefined; + } + return path.join(global, 'nxmhandler.ini'); +} + +export async function FindNXMHandlerIniPath(): Promise { + const nxmHandlerIniPath = GetNXMHandlerIniPath(); + if (nxmHandlerIniPath === undefined || !existsSync(nxmHandlerIniPath)) { + return undefined; + } + return nxmHandlerIniPath; +} + +export async function IsInstanceOfGame(gameID: MO2LongGameID, instanceIniPath: string): Promise { + const iniData = await ParseIniFile(instanceIniPath); + if (iniData === undefined) { + return false; + } + return _isInstanceOfGames(iniData, gameID); +} + +function _isInstanceOfGames( + instanceIniData: INIData, + gameID: MO2LongGameID, + ...additionalIds: MO2LongGameID[] +): boolean { + if (instanceIniData.General === undefined || instanceIniData.General.gameName === undefined) { + return false; + } + + const gameIDs = [gameID, ...additionalIds]; + for (const id of gameIDs) { + if (instanceIniData.General.gameName === id) { + return true; + } + } + return false; +} + +export async function FindGlobalInstance(name: string): Promise { + const globalFolder = GetGlobalMO2DataFolder(); + if (globalFolder === undefined || (!existsSync(globalFolder) && !fs.statSync(globalFolder).isDirectory())) { + return undefined; + } + const instanceNames = readdirSync(globalFolder, { withFileTypes: true }); + const instance = instanceNames.find((dirent) => dirent.isDirectory() && dirent.name === name); + if (instance === undefined) { + return undefined; + } + const instanceIniPath = path.join(globalFolder, instance.name, InstanceIniName); + const iniData = await ParseIniFile(instanceIniPath); + if (!iniData) { + return undefined; + } + return ParseInstanceINI(instanceIniPath, iniData, false); +} + +export async function FindGlobalInstances( + gameId?: MO2LongGameID, + ...additionalIds: MO2LongGameID[] +): Promise { + const possibleLocations: MO2InstanceInfo[] = []; + const globalFolder = GetGlobalMO2DataFolder(); + // list all the directories in globalMO2Data + if (globalFolder === undefined || (!existsSync(globalFolder) && !fs.statSync(globalFolder).isDirectory())) { + return []; + } + const instanceNames = readdirSync(globalFolder, { withFileTypes: true }); + for (const dirent of instanceNames) { + if (dirent.isDirectory()) { + const instanceIniPath = path.join(globalFolder, dirent.name, InstanceIniName); + const iniData = await ParseIniFile(instanceIniPath); + if (!iniData) { + continue; + } + if (gameId !== undefined && !_isInstanceOfGames(iniData, gameId, ...additionalIds)) { + continue; + } + const info = ParseInstanceINI(instanceIniPath, iniData, false); + if (info !== undefined) { + possibleLocations.push(info); + } + } + } + return possibleLocations; +} + +export async function GetCurrentGlobalInstance(): Promise { + // HKEY_CURRENT_USER\SOFTWARE\Mod Organizer Team\Mod Organizer\CurrentInstance + const currentInstanceName = await getRegistryValueData( + 'SOFTWARE\\Mod Organizer Team\\Mod Organizer', + 'CurrentInstance', + 'HKCU' + ); + if (currentInstanceName) { + return FindGlobalInstance(currentInstanceName); + } + return undefined; +} + +/** + * Parse modlist.txt file contents from Mod Organizer 2 + * + * The format is: + * - Mod names are the names of their directories in the mods folder + * i.e. "SkyUI", not "SkyUI.esp", with an exception for official DLC, which is prefixed with "DLC: " + * - Comments are prefixed with '#' + * - Enabled mods are prefixed with "+" and disabled mods are prefixed with "-" + * - Unmanaged mods (e.g. Game DLC) are prefixed with "*" + * - Seperators are prefixed with "-" and have the suffix "_separator" + * - The mods are loaded in order. + * - Any mods listed earlier will overwrite files in mods listed later. + * They appear in the ModOrganizer gui in reverse order (i.e. the last mod in the file is the first mod in the gui) + * + * Example of a modlist.txt file: + * ```none + * # This file was automatically generated by Mod Organizer. + * +Unofficial Skyrim Special Edition Patch + * -SkyUI + * +Immersive Citizens - AI Overhaul + * *DLC: Automatron + * +Immersive Citizens - OCS patch + * -Auto Loot SE + * *DLC: Far Harbor + * *DLC: Contraptions Workshop + * ``` + * This function returns an array of mod list items in the order they appear in the file + */ + +export function ParseModListText(modListContents: string): ModListItem[] { + const modlist = new Array(); + const modlistLines = modListContents.replace(/\r\n/g, '\n').split('\n'); + for (const line of modlistLines) { + if (line.charAt(0) === '#' || line === '') { + continue; + } + const indic = line.charAt(0); + const modName = line.substring(1); + let modEnabledState: ModEnabledState | undefined = undefined; + switch (indic) { + case '+': + modEnabledState = ModEnabledState.enabled; + break; + case '-': + modEnabledState = ModEnabledState.disabled; + break; + case '*': + modEnabledState = ModEnabledState.unmanaged; + break; + } + if (modEnabledState === undefined) { + // skip this line + continue; + } + modlist.push(new ModListItem(modName, modEnabledState)); + } + return modlist; +} + +export async function ParseModListFile(modlistPath: string): Promise { + // create an ordered map of mod names to their enabled state + if (!fs.existsSync(modlistPath) || !fs.lstatSync(modlistPath).isFile()) { + return undefined; + } + const modlistContents = readFileSync(modlistPath, 'utf8').replace(/\r\n/g, '\n'); + if (!modlistContents) { + return undefined; + } + return ParseModListText(modlistContents); +} +// parse moshortcut URI + +export function parseMoshortcutURI(moshortcutURI: string): { instanceName: string; exeName: string } { + const moshortcutparts = moshortcutURI.replace('moshortcut://', '').split(':'); + const instanceName = moshortcutparts[0] || 'portable'; + const exeName = moshortcutparts[1]; + return { instanceName, exeName }; +} + +export function checkIfModExistsAndEnabled(modlist: Array, modName: string) { + return modlist.findIndex((mod) => mod.name === modName && mod.enabled === ModEnabledState.enabled) !== -1; +} + +/** + * If we find the mod in the modlist, remove it and return the new modlist + * @param modlist + * @param modName + * @returns + */ + +export function IndexOfModList(modlist: Array, modName: string) { + return modlist.findIndex((m) => m.name === modName); +} + +export function RemoveMod(modlist: Array, modName: string) { + const modIndex = modlist.findIndex((m) => m.name === modName); + if (modIndex !== -1) { + return modlist.slice(0, modIndex).concat(modlist.slice(modIndex + 1)); + } + return modlist; +} + +export function AddModToBeginningOfModList(modlist: Array, mod: ModListItem) { + // check if the mod is already in the modlist + const modIndex = modlist.findIndex((m) => m.name === mod.name); + if (modIndex !== -1) { + // if the mod is already in the modlist, remove it and return the modlist with the specified mod at the top + return [mod].concat(modlist.slice(0, modIndex).concat(modlist.slice(modIndex + 1))); + } + return [mod].concat(modlist); +} + +export function AddModIfNotInModList(modlist: Array, mod: ModListItem) { + // check if the mod is already in the modlist + const modIndex = IndexOfModList(modlist, mod.name); + if (modIndex === -1) { + // if the mod is not already in the modlist, add it at the beginning + return [mod].concat(modlist); + } + // otherwise just return it + return modlist; +} + +export function AddOrEnableModInModList(modlist: Array, modName: string) { + const modIndex = modlist.findIndex((m) => m.name === modName); + if (modIndex !== -1) { + return modlist + .slice(0, modIndex) + .concat(new ModListItem(modName, ModEnabledState.enabled), modlist.slice(modIndex + 1)); + } + return AddModToBeginningOfModList(modlist, new ModListItem(modName, ModEnabledState.enabled)); +} + +// modlist.txt has to be in CRLF, because MO2 is cursed +export function ModListToText(modlist: Array) { + let modlistText = '# This file was automatically generated by Mod Organizer.\r\n'; + for (const mod of modlist) { + modlistText += mod.enabled + mod.name + '\r\n'; + } + return modlistText; +} + +export function WriteChangesToModListFile(modlistPath: string, modlist: Array) { + const modlistContents = ModListToText(modlist); + fs.rmSync(modlistPath, { force: true }); + if (!openSync(modlistPath, 'w')) { + return false; + } + writeFileSync(modlistPath, modlistContents, 'utf8'); + return true; +} + +/** + * MO2 handles exe arguments awfully, which is why we have to do this tortured parsing. + * Don't use this for anything other than MO2, because it's not a general purpose parser + * Note: This is not used for parsing the custom executable objects; args are stored as the literal there + * + * In MO2, this is just a string, and the only argument passed to an executable is that string, instead of as an array of arguments. + * cmd.exe ends up mangling the arguments if they contain quote-literals. + */ +export function ParseMO2CmdLineArguments(normargstring: string) { + const args: string[] = []; + let arg = ''; + let inQuote = false; + for (let i = 0; i < normargstring.length; i++) { + const char = normargstring[i]; + // if we hit a space, and we're not in a quote, then we've hit the end of an argument + if (char === ' ') { + if (!inQuote && arg.length > 0) { + args.push(arg); + arg = ''; + } else { + arg += char; + } + } else if (char === '"') { + // if we hit a quote, and we're not in a quote, then we're starting a quote + if (inQuote === false) { + // If the arg started, add the quote to it + if (arg.length > 0) { + arg += char; + } + // Even if the above is true, we're still starting a quote + inQuote = true; + } else { + // if we hit a quote, and we're in a quote, then we're ending a quote + inQuote = false; + // peek ahead to see if the next character is a space + if (i !== normargstring.length - 1 && normargstring[i + 1] !== ' ') { + arg += char; + } + + // if the argument already has a quote literal in it, then we need to add the quote to the arg + else if (arg.indexOf('"') !== -1) { + arg += char; + } + } + } else { + arg += char; + } + } + // get the last one if there was one + if (arg.length > 0 && arg.trim() !== '') { + args.push(arg); + } + return args; +} +/*** + * Format is like this: + * ```none + * [General] + * gameName=Fallout4 + * modid=47327 + * ignoredVersion= + * version=1.10.163.0 + * newestVersion=1.10.163.0 + * category="35," + * nexusFileStatus=1 + * installationFile=Addres Library-47327-1-10-163-0-1599728753.zip + * repository=Nexus + * comments= + * notes= + * nexusDescription="This project is a resource for plugins developed using [url=https://github.com/Ryan-rsm-McKenzie/CommonLibF4]CommonLibF4.[/url]" + * url= + * hasCustomURL=false + * lastNexusQuery=2022-12-23T00:08:20Z + * lastNexusUpdate=2022-12-23T00:08:20Z + * nexusLastModified=2020-09-10T09:08:54Z + * converted=false + * validated=false + * color=@Variant(\0\0\0\x43\0\xff\xff\0\0\0\0\0\0\0\0) + * endorsed=0 + * tracked=0 + * + * [installedFiles] + * 1\modid=47327 + * 1\fileid=191018 + * size=1 + * ``` + * + * the only required fields are: + * - modid + * - version + * - newestVersion + * - category + * - installationFile + * - [installedFiles].size + */ + +export function isKeyOfObject(key: string | number | symbol, obj: T): key is keyof T { + return key in obj; +} + +function ParseModMetaIni(modMetaIni: INIData): MO2ModMeta | undefined { + if (!modMetaIni) { + return undefined; + } + if (modMetaIni.farts === undefined) { + console.log('lmao'); + } + if ( + modMetaIni.General === undefined || + modMetaIni.General.modid === undefined || + modMetaIni.General.version === undefined || + modMetaIni.General.newestVersion === undefined || + modMetaIni.General.installationFile === undefined || + modMetaIni.General.category === undefined || + modMetaIni.installedFiles === undefined || + modMetaIni.installedFiles.size === undefined + ) { + return undefined; + } + const general = modMetaIni.General; + // check if each key in general is a key in the type MO2ModMeta + // TODO: figure out how to do this without the any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const modMeta = {} as any; + for (const key in general) { + if (isKeyOfObject(key, general as MO2ModMeta)) { + modMeta[key] = general[key]; + } + } + const installedFilesSize = modMetaIni.installedFiles.size; + if (!installedFilesSize) { + return undefined; + } + const installedFiles = ParseIniArray(modMetaIni.installedFiles); + if (!installedFiles) { + return undefined; + } + modMeta['installedFiles'] = installedFiles.map((installedFile) => { + return { + modid: installedFile.modid, + fileid: installedFile.fileid, + } as MO2ModMetaInstalledFile; + }); + return modMeta as MO2ModMeta; +} + +export function SerializeModMetaInfo(info: MO2ModMeta) { + const ini = {} as INIData; + ini.General = {} as INIData; + Object.keys(info).forEach((key) => { + if (key !== 'installedFiles' && info[key as keyof MO2ModMeta] !== undefined) { + ini.General[key] = info[key as keyof MO2ModMeta]; + } + }); + ini.installedFiles = SerializeIniArray(info.installedFiles); + return ini; +} + +export async function ParseModMetaIniFile(modMetaIniPath: string) { + const modMetaIni = await ParseIniFile(modMetaIniPath); + if (!modMetaIni) { + return undefined; + } + return ParseModMetaIni(modMetaIni); +} + +export function AddSeparatorToBeginningOfModList(name: string, modList: ModListItem[]): ModListItem[] { + return AddModIfNotInModList(modList, new ModListItem(name + '_separator', ModEnabledState.disabled)); +} diff --git a/src/papyrus-lang-vscode/src/common/OSHelpers.ts b/src/papyrus-lang-vscode/src/common/OSHelpers.ts new file mode 100644 index 0000000..b2705f7 --- /dev/null +++ b/src/papyrus-lang-vscode/src/common/OSHelpers.ts @@ -0,0 +1,28 @@ +import { promisify } from 'util'; +import winreg from 'winreg'; + +export function getLocalAppDataFolder() { + return process.env.LOCALAPPDATA; +} +export function getHomeFolder() { + return process.env.HOMEPATH; +} +export function getUserName() { + return process.env.USERNAME; +} +export function getTempFolder() { + return process.env.TEMP; +} +export async function getRegistryValueData(key: string, value: string, hive: string = 'HKLM') { + const reg = new winreg({ + hive, + key, + }); + try { + const item = await promisify(reg.get).call(reg, value); + return item.value; + } catch (e) { + /* empty */ + } + return null; +} diff --git a/src/papyrus-lang-vscode/src/common/PathResolver.ts b/src/papyrus-lang-vscode/src/common/PathResolver.ts index b3f7031..ee0a887 100644 --- a/src/papyrus-lang-vscode/src/common/PathResolver.ts +++ b/src/papyrus-lang-vscode/src/common/PathResolver.ts @@ -3,7 +3,6 @@ import * as path from 'path'; import { inject, injectable, interfaces } from 'inversify'; import { take } from 'rxjs/operators'; -import winreg from 'winreg'; import { promisify } from 'util'; import { ExtensionContext } from 'vscode'; @@ -12,12 +11,16 @@ import { IExtensionContext } from '../common/vscode/IocDecorators'; import { PapyrusGame, getScriptExtenderName } from '../PapyrusGame'; import { inDevelopmentEnvironment } from '../Utilities'; import { IExtensionConfigProvider, IGameConfig } from '../ExtensionConfigProvider'; +import { PDSModName } from './constants'; +import { DetermineGameVariant, FindGamePath, FindUserGamePath } from './GameHelpers'; const exists = promisify(fs.exists); export interface IPathResolver { // Internal paths getDebugPluginBundledPath(game: PapyrusGame): Promise; + getAddressLibraryDownloadFolder(): Promise; + getAddressLibraryDownloadJSON(): Promise; getLanguageToolPath(game: PapyrusGame): Promise; getDebugToolPath(game: PapyrusGame): Promise; getPyroCliPath(): Promise; @@ -26,7 +29,9 @@ export interface IPathResolver { getWelcomeFile(): Promise; // External paths getInstallPath(game: PapyrusGame): Promise; + getUserGamePath(game: PapyrusGame): Promise; getModDirectoryPath(game: PapyrusGame): Promise; + getModParentPath(game: PapyrusGame): Promise; getDebugPluginInstallPath(game: PapyrusGame, legacy?: boolean): Promise; } @@ -59,8 +64,9 @@ export class PathResolver implements IPathResolver { return `Data/${getScriptExtenderName(game)}/Plugins`; } + // TODO: Refactor this properly // For mod managers. The whole directory for the mod is "Data" so omit that part. - private async _getModMgrExtenderPluginPath(game: PapyrusGame) { + public static _getModMgrExtenderPluginRelativePath(game: PapyrusGame) { return `${getScriptExtenderName(game)}/Plugins`; } // Public Methods @@ -70,7 +76,19 @@ export class PathResolver implements IPathResolver { /************************************************************************* */ public async getDebugPluginBundledPath(game: PapyrusGame) { - return this._asExtensionAbsolutePath(path.join(bundledPluginPath, getPluginDllName(game))); + const dll = getPluginDllName(game); + if (!dll) { + throw new Error('Debugging not supported for game ' + game); + } + return this._asExtensionAbsolutePath(path.join(bundledPluginPath, dll)); + } + + public async getAddressLibraryDownloadFolder() { + return this._asExtensionAbsolutePath(downloadedAddressLibraryPath); + } + + public async getAddressLibraryDownloadJSON() { + return this._asExtensionAbsolutePath(path.join(downloadedAddressLibraryPath, addlibManifestName)); } public async getLanguageToolPath(game: PapyrusGame): Promise { @@ -114,14 +132,20 @@ export class PathResolver implements IPathResolver { return resolveInstallPath(game, config.installPath, this._context); } + public async getUserGamePath(game: PapyrusGame): Promise { + const config = await this._getGameConfig(game); + return resolveUserGamePath(game, config.installPath, this._context); + } + + // TODO: Refactor this properly. public async getDebugPluginInstallPath(game: PapyrusGame, legacy?: boolean): Promise { const modDirectoryPath = await this.getModDirectoryPath(game); if (modDirectoryPath) { return path.join( modDirectoryPath, - 'Papyrus Debug Extension', - await this._getModMgrExtenderPluginPath(game), + PDSModName, + PathResolver._getModMgrExtenderPluginRelativePath(game), getPluginDllName(game, legacy) ); } else { @@ -143,6 +167,23 @@ export class PathResolver implements IPathResolver { return config.modDirectoryPath; } + /** + * If the mod directory is set, then this just returns the mod directory + * Otherwise, it returns "${game directory}/Data" + * @param game + * @returns + */ + public async getModParentPath(game: PapyrusGame): Promise { + const modDirectoryPath = await this.getModDirectoryPath(game); + if (modDirectoryPath) { + return modDirectoryPath; + } + const installPath = await this.getInstallPath(game); + if (!installPath) { + return null; + } + return path.join(installPath, 'Data'); + } dispose() {} } @@ -153,15 +194,16 @@ export const IPathResolver: interfaces.ServiceIdentifier = Symbol /************************************************************************* */ const bundledPluginPath = 'debug-plugin'; - -function getPluginDllName(game: PapyrusGame, legacy = false) { +const downloadedAddressLibraryPath = 'debug-address-library'; +const addlibManifestName = 'address-library.json'; +export function getPluginDllName(game: PapyrusGame, legacy = false) { switch (game) { case PapyrusGame.fallout4: return legacy ? 'DarkId.Papyrus.DebugServer.dll' : 'DarkId.Papyrus.DebugServer.Fallout4.dll'; case PapyrusGame.skyrimSpecialEdition: return 'DarkId.Papyrus.DebugServer.Skyrim.dll'; default: - throw new Error(`'${game}' is not supported by the Papyrus debugger.`); + throw new Error('Debugging not supported for game ' + game); } } @@ -179,28 +221,6 @@ function getToolGameName(game: PapyrusGame): string { /*** External paths (ones that are not "ours") */ /************************************************************************* */ -function getRegistryKeyForGame(game: PapyrusGame) { - switch (game) { - case PapyrusGame.fallout4: - return 'Fallout4'; - case PapyrusGame.skyrim: - return 'Skyrim'; - case PapyrusGame.skyrimSpecialEdition: - return 'Skyrim Special Edition'; - } -} - -export function getDevelopmentCompilerFolderForGame(game: PapyrusGame) { - switch (game) { - case PapyrusGame.fallout4: - return 'fallout4'; - case PapyrusGame.skyrim: - return 'does-not-exist'; - case PapyrusGame.skyrimSpecialEdition: - return 'skyrim'; - } -} - export async function resolveInstallPath( game: PapyrusGame, installPath: string, @@ -209,23 +229,12 @@ export async function resolveInstallPath( if (await exists(installPath)) { return installPath; } - - const reg = new winreg({ - key: `\\SOFTWARE\\${process.arch === 'x64' ? 'WOW6432Node\\' : ''}Bethesda Softworks\\${getRegistryKeyForGame( - game - )}`, - }); - - try { - const item = await promisify(reg.get).call(reg, 'installed path'); - - if (await exists(item.value)) { - return item.value; - } - } catch (_) { - // empty on purpose + const pathValue = await FindGamePath(game); + if (pathValue) { + return pathValue; } + // TODO: @joelday, what is this for? if (inDevelopmentEnvironment() && game !== PapyrusGame.skyrim) { return context.asAbsolutePath('../../dependencies/compilers'); } @@ -233,18 +242,20 @@ export async function resolveInstallPath( return null; } -export function getDefaultFlagsFileNameForGame(game: PapyrusGame) { - return game === PapyrusGame.fallout4 ? 'Institute_Papyrus_Flags.flg' : 'TESV_Papyrus_Flags.flg'; -} - -const executableNames = new Map([ - [PapyrusGame.skyrim, 'Skyrim.exe'], - [PapyrusGame.fallout4, 'Fallout4.exe'], - [PapyrusGame.skyrimSpecialEdition, 'SkyrimSE.exe'], -]); - -export function getExecutableNameForGame(game: PapyrusGame) { - return executableNames.get(game)!; +async function resolveUserGamePath( + game: PapyrusGame, + installPath: string, + context: ExtensionContext +): Promise { + let _installPath: string | null = installPath; + if (!(await exists(installPath))) { + _installPath = await resolveInstallPath(game, installPath, context); + } + if (!installPath) { + return null; + } + const variant = await DetermineGameVariant(game, installPath); + return FindUserGamePath(game, variant); } export function pathToOsPath(pathName: string) { diff --git a/src/papyrus-lang-vscode/src/common/constants.ts b/src/papyrus-lang-vscode/src/common/constants.ts index 2c70b2d..451a529 100644 --- a/src/papyrus-lang-vscode/src/common/constants.ts +++ b/src/papyrus-lang-vscode/src/common/constants.ts @@ -7,3 +7,18 @@ export const extensionQualifiedId = `joelday.${extensionId}`; export enum GlobalState { PapyrusVersion = 'papyrusVersion', } +export const PDSModName = 'Papyrus Debug Extension'; +export const AddressLibraryF4SEModName = 'Address Library for F4SE Plugins'; +export const AddressLibrarySKSEAEModName = 'Address Library for SKSE Plugins (AE)'; +export const AddressLibrarySKSEModName = 'Address Library for SKSE Plugins'; + +// TODO: Move these elsewhere +export type AddressLibraryName = + | typeof AddressLibraryF4SEModName + | typeof AddressLibrarySKSEAEModName + | typeof AddressLibrarySKSEModName; +export enum AddressLibAssetSuffix { + SkyrimSE = 'SkyrimSE', + SkyrimAE = 'SkyrimAE', + Fallout4 = 'Fallout4', +} diff --git a/src/papyrus-lang-vscode/src/debugger/AddLibHelpers.ts b/src/papyrus-lang-vscode/src/debugger/AddLibHelpers.ts new file mode 100644 index 0000000..7dc9342 --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/AddLibHelpers.ts @@ -0,0 +1,387 @@ +import { GithubRelease } from '@terascope/fetch-github-release/dist/src/interfaces'; +import { AddressLibAssetSuffix, AddressLibraryName } from '../common/constants'; +import { getAddressLibNameFromAssetSuffix, getAddressLibNames, getAsssetLibraryDLSuffix } from '../common/GameHelpers'; +import { + DownloadAssetAndCheckHash, + downloadAssetFromGitHub, + DownloadResult, + GetLatestReleaseFromRepo, +} from '../common/GithubHelpers'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import path from 'path'; +import extractZip from 'extract-zip'; +import { PapyrusGame } from '../PapyrusGame'; +import { CheckHashFile, GetHashOfFolder, mkdirIfNeeded } from '../Utilities'; +const exists = promisify(fs.exists); +const readFile = promisify(fs.readFile); +const lstat = promisify(fs.lstat); + +export const AddLibRepoUserName = 'nikitalita'; +export const AddLibRepoName = 'address-library-dist'; + +export interface AddressLibReleaseAssetList { + version: string; + // The name of the zip file + SkyrimSE: Asset; + // The name of the zip file + SkyrimAE: Asset; + // The name of the zip file + Fallout4: Asset; +} + +export interface Asset { + /** + * The file name of the zip file + */ + zipFile: string; + folderName: AddressLibraryName; + zipFileHash: string; + /** + * For checking if the installed folder has the same folder hash as the one we have + */ + folderHash: string; +} + +export function _getAsset(assetList: AddressLibReleaseAssetList, suffix: AddressLibAssetSuffix): Asset | undefined { + return assetList[suffix]; +} + +export function AddLibHelpers() {} +export function GetAssetZipForSuffixFromRelease( + release: GithubRelease, + name: AddressLibAssetSuffix +): string | undefined { + const _assets = release.assets.filter((asset) => asset.name.indexOf(name) >= 0); + if (_assets.length == 0) { + return undefined; + } else if (_assets.length > 1) { + // This should never happen + throw new Error('Too many assets found for suffix: ' + name + ''); + } + return _assets[0].name; +} + +export function getAssetListFromAddLibRelease(release: GithubRelease): AddressLibReleaseAssetList | undefined { + let assetZip: string | undefined | Error; + const ret: AddressLibReleaseAssetList = new Object() as AddressLibReleaseAssetList; + ret.version = release.tag_name; + for (const idx in AddressLibAssetSuffix) { + const assetSuffix: AddressLibAssetSuffix = AddressLibAssetSuffix[idx as keyof typeof AddressLibAssetSuffix]; + assetZip = GetAssetZipForSuffixFromRelease(release, assetSuffix); + if (!assetZip) { + return undefined; + } + ret[assetSuffix] = { + zipFile: assetZip, + folderName: getAddressLibNameFromAssetSuffix(assetSuffix), + zipFileHash: '0', + folderHash: '0', + }; + } + return ret; +} + +export async function getLatestAddLibReleaseInfo(): Promise { + let latestReleaseInfo: GithubRelease | undefined; + try { + latestReleaseInfo = await GetLatestReleaseFromRepo(AddLibRepoUserName, AddLibRepoName, false); + if (!latestReleaseInfo) { + return undefined; + } + } catch (e) { + return undefined; + } + return latestReleaseInfo; +} + +interface CancellationToken { + isCancellationRequested: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onCancellationRequested: any; +} + +export async function DownloadLatestAddressLibs( + downloadFolder: string, + AssetListDLPath: string, + cancellationToken: CancellationToken +) { + const latestReleaseInfo = await getLatestAddLibReleaseInfo(); + if (!latestReleaseInfo) { + return DownloadResult.repoFailure; + } + const assetList = getAssetListFromAddLibRelease(latestReleaseInfo); + if (!assetList) { + return DownloadResult.repoFailure; + } + const release_id = latestReleaseInfo.id; + // get the shasums + let sha256SumsPath: string | undefined; + try { + sha256SumsPath = await downloadAssetFromGitHub( + AddLibRepoUserName, + AddLibRepoName, + release_id, + 'SHA256SUMS.json', + downloadFolder + ); + } catch (e) { + return DownloadResult.sha256sumDownloadFailure; + } + if (!sha256SumsPath || !(await exists(sha256SumsPath)) || !(await lstat(sha256SumsPath)).isFile()) { + return DownloadResult.sha256sumDownloadFailure; + } + const sha256buf = await readFile(sha256SumsPath, 'utf8'); + if (!sha256buf) { + return DownloadResult.sha256sumDownloadFailure; + } + const sha256Sums = JSON.parse(sha256buf); + const retryLimit = 3; + let retries = 0; + for (const idx in AddressLibAssetSuffix) { + const assetSuffix: AddressLibAssetSuffix = AddressLibAssetSuffix[idx as keyof typeof AddressLibAssetSuffix]; + + if (cancellationToken.isCancellationRequested) return DownloadResult.cancelled; + + retries = 0; + const asset = _getAsset(assetList, assetSuffix); + if (!asset) { + return DownloadResult.repoFailure; + } + const expectedHash = sha256Sums[asset.zipFile]; + if (!expectedHash) { + return DownloadResult.repoFailure; + } + + let ret: DownloadResult = DownloadResult.downloadFailure; + while (retries < retryLimit && cancellationToken.isCancellationRequested == false) { + ret = await DownloadAssetAndCheckHash( + AddLibRepoUserName, + AddLibRepoName, + release_id, + asset.zipFile, + downloadFolder, + expectedHash + ); + if (ret == DownloadResult.success) { + break; + } + retries++; + } + + if (cancellationToken.isCancellationRequested) return DownloadResult.cancelled; + + if (ret != DownloadResult.success) { + return ret; + } + asset.zipFileHash = expectedHash; + const zipFilePath = path.join(downloadFolder, asset.zipFile); + // We extract the zip here to check the hash of the folder when we check the install state + // We don't end up installing from the folder, we install from the zip + const ExtractedFolderPath = path.join(downloadFolder, asset.folderName); + fs.rmSync(ExtractedFolderPath, { recursive: true, force: true }); + await extractZip(zipFilePath, { dir: ExtractedFolderPath }); + if (cancellationToken.isCancellationRequested) return DownloadResult.cancelled; + if (!(await _checkAddlibExtracted(asset.folderName, downloadFolder))) { + return DownloadResult.filesystemFailure; + } + const folderHash = await GetHashOfFolder(ExtractedFolderPath); + if (!folderHash) { + return DownloadResult.filesystemFailure; + } + asset.folderHash = folderHash; + if (cancellationToken.isCancellationRequested) return DownloadResult.cancelled; + // Remove it, because we don't install from it + fs.rmSync(ExtractedFolderPath, { recursive: true, force: true }); + assetList[assetSuffix] = asset; + } + // we do this last to make sure we don't write a corrupt json file + fs.writeFileSync(AssetListDLPath, JSON.stringify(assetList)); + return DownloadResult.success; +} + +export async function GetAssetList(jsonPath: string) { + if (!fs.existsSync(jsonPath) || !fs.lstatSync(jsonPath).isFile()) { + return undefined; + } + const contents = fs.readFileSync(jsonPath, 'utf8'); + if (!contents || contents.length == 0) { + // json is corrupt + return undefined; + } + const assetList = JSON.parse(contents) as AddressLibReleaseAssetList; + if (!assetList) { + // json is corrupt + return undefined; + } + // check integrity + for (const idx in AddressLibAssetSuffix) { + const assetSuffix: AddressLibAssetSuffix = AddressLibAssetSuffix[idx as keyof typeof AddressLibAssetSuffix]; + const currentAsset = _getAsset(assetList, assetSuffix); + if (!currentAsset) { + // json is corrupt + return undefined; + } + if ( + !currentAsset.zipFile || + !currentAsset.zipFileHash || + !currentAsset.folderName || + !currentAsset.folderHash + ) { + // json is corrupt + return undefined; + } + } + return assetList; +} + +export async function _checkDownloadIntegrity( + downloadpath: string, + assetList: AddressLibReleaseAssetList +): Promise { + if (!assetList) { + return false; + } + for (const idx in AddressLibAssetSuffix) { + const assetSuffix: AddressLibAssetSuffix = AddressLibAssetSuffix[idx as keyof typeof AddressLibAssetSuffix]; + const currentAsset = _getAsset(assetList, assetSuffix); + if (!currentAsset) { + return false; + } + const assetName = currentAsset.zipFile; + const assetPath = path.join(downloadpath, assetName); + if (!(await exists(assetPath))) { + return false; + } + if (!CheckHashFile(assetPath, currentAsset.zipFileHash)) { + return false; + } + } + return true; +} + +/** + * This checks to see if the ${modsDir}/${name}/{SK,F4}SE/Plugins folder has at least one file in it + * @param name - the name of the address library + * @param modsDir - the mods directory to check in + * @returns true or false + */ +export async function _checkAddlibExtracted(name: AddressLibraryName, modsDir: string): Promise { + const addressLibInstallPath = path.join(modsDir, name); + // TODO: refactor this + const SEDIR = name.indexOf('SKSE') >= 0 ? 'SKSE' : 'F4SE'; + const pluginsdir = path.join(addressLibInstallPath, SEDIR, 'Plugins'); + + if (!fs.existsSync(pluginsdir) || !fs.lstatSync(pluginsdir).isDirectory()) { + return false; + } + const files = fs.readdirSync(pluginsdir, { withFileTypes: true }); + if (files.length == 0) { + return false; + } + return true; +} +enum AddressLibInstalledState { + notInstalled, + installed, + outdated, + installedButCantCheckForUpdates, +} +/** + * Gets the state of the address library install + * + * @param name The name of the address library + * @param modsDir The mods directory to check in + * @param assetList The downloaded Address Library asset list to check against. + * If not provided, we don't check if it's outdated + * @returns AddressLibInstalledState + */ +export async function _checkAddressLibInstalled( + name: AddressLibraryName, + modsDir: string, + assetList?: AddressLibReleaseAssetList +): Promise { + if (!(await _checkAddlibExtracted(name, modsDir))) { + return AddressLibInstalledState.notInstalled; + } + if (assetList) { + const addressLibInstallPath = path.join(modsDir, name); + const suffix = getAsssetLibraryDLSuffix(name); + const asset = _getAsset(assetList, suffix); + if (!asset) { + throw new Error('Asset list is corrupt'); + } + const folderHash = await GetHashOfFolder(addressLibInstallPath); + if (!folderHash) { + return AddressLibInstalledState.notInstalled; + } + if (folderHash != asset.folderHash) { + return AddressLibInstalledState.outdated; + } + } + return AddressLibInstalledState.installed; +} + +/** + * @param game The game to check for + * @param modsDir The mods directory to check in + * @param assetList The downloaded Address Library asset list to check against. + * If not provided, we don't check if it's outdated + * @returns AddressLibInstalledState + */ +export async function _checkAddressLibsInstalled( + game: PapyrusGame, + modsDir: string, + assetList?: AddressLibReleaseAssetList +): Promise { + const addressLibFolderNames = getAddressLibNames(game); + for (const name of addressLibFolderNames) { + const state = await _checkAddressLibInstalled(name, modsDir, assetList); + if (state !== AddressLibInstalledState.installed) { + return state; + } + } + return AddressLibInstalledState.installed; +} + +// TODO: For some godforsaken reason, the address library names on Nexus mods for both SE and AE are the same. +// (i.e. "Address Library for SKSE Plugins") +// Need to handle this +export async function _installAddressLibs( + game: PapyrusGame, + ParentInstallDir: string, + downloadDir: string, + assetList: AddressLibReleaseAssetList, + cancellationToken: CancellationToken +): Promise { + const addressLibNames = getAddressLibNames(game); + for (const name of addressLibNames) { + if (cancellationToken.isCancellationRequested) { + return false; + } + const addressLibInstallPath = path.join(ParentInstallDir, name); + // The reason we check each individiual library is that Skyrim currently requires two libraries, + // So we want to make sure we don't overwrite one that is already installed + // We don't currently check if the library is outdated or not + const state = await _checkAddressLibInstalled(name, ParentInstallDir); + if (state === AddressLibInstalledState.installed) { + // It's installed and we're not forcing updates, so we don't need to do anything + continue; + } + const suffix = getAsssetLibraryDLSuffix(name); + const asset = _getAsset(assetList, suffix); + if (!asset) { + throw new Error('Asset list is corrupt'); + } + const zipPath = path.join(downloadDir, asset.zipFile); + // fs.rmSync(addressLibInstallPath, { recursive: true, force: true }); + await mkdirIfNeeded(addressLibInstallPath); + await extractZip(zipPath, { + dir: addressLibInstallPath, + }); + if (!(await _checkAddressLibInstalled(name, ParentInstallDir))) { + return false; + } + } + return true; +} diff --git a/src/papyrus-lang-vscode/src/debugger/AddressLibInstallService.ts b/src/papyrus-lang-vscode/src/debugger/AddressLibInstallService.ts new file mode 100644 index 0000000..768eeae --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/AddressLibInstallService.ts @@ -0,0 +1,168 @@ +import { inject, injectable, interfaces } from 'inversify'; +import { IPathResolver } from '../common/PathResolver'; +import { PapyrusGame } from '../PapyrusGame'; +import { DownloadResult } from '../common/GithubHelpers'; +import { CancellationToken, CancellationTokenSource } from 'vscode'; + +import * as AddLib from './AddLibHelpers'; + +export enum AddressLibDownloadedState { + notDownloaded, + latest, + outdated, + downloadedButCantCheckForUpdates, +} + +export enum AddressLibInstalledState { + notInstalled, + installed, + outdated, + installedButCantCheckForUpdates, +} + +export interface IAddressLibraryInstallService { + getInstallState(game: PapyrusGame, modsDir?: string): Promise; + getDownloadedState(): Promise; + DownloadLatestAddressLibs(cancellationToken?: CancellationToken): Promise; + installLibraries( + game: PapyrusGame, + forceDownload: boolean, + cancellationToken?: CancellationToken, + modsDir?: string + ): Promise; +} + +@injectable() +export class AddressLibraryInstallService implements IAddressLibraryInstallService { + private readonly _pathResolver: IPathResolver; + + constructor(@inject(IPathResolver) pathResolver: IPathResolver) { + this._pathResolver = pathResolver; + } + + public async DownloadLatestAddressLibs( + cancellationToken = new CancellationTokenSource().token + ): Promise { + const addressLibDownloadPath = await this._pathResolver.getAddressLibraryDownloadFolder(); + const addressLibDLJSONPath = await this._pathResolver.getAddressLibraryDownloadJSON(); + const status = await AddLib.DownloadLatestAddressLibs( + addressLibDownloadPath, + addressLibDLJSONPath, + cancellationToken + ); + return status; + } + + private async getCurrentDownloadedAssetList(): Promise { + const addressLibDLJSONPath = await this._pathResolver.getAddressLibraryDownloadJSON(); + return AddLib.GetAssetList(addressLibDLJSONPath); + } + + private async checkDownloadIntegrity(): Promise { + const addressLibDownloadPath = await this._pathResolver.getAddressLibraryDownloadFolder(); + const assetList = await this.getCurrentDownloadedAssetList(); + if (!assetList) { + return false; + } + return await AddLib._checkDownloadIntegrity(addressLibDownloadPath, assetList); + } + + /** + * Gets the state of the address library download + * - If the address library is not downloaded or the download is corrupt, it will return `notDownloaded` + * - If the address library is downloaded but the version can't be checked, it will return `downloadedButCantCheck` + * - If the address library is downloaded but the version is outdated, it will return `outdated` + * - If the address library is downloaded and the version is up to date, it will return `latest` + * @returns AddressLibDownloadedState + */ + public async getDownloadedState(): Promise { + // If it's not downloaded or the download is corrupt, we return notDownloaded + if (!(await this.checkDownloadIntegrity())) { + return AddressLibDownloadedState.notDownloaded; + } + const assetList = await this.getCurrentDownloadedAssetList(); + if (!assetList) { + return AddressLibDownloadedState.notDownloaded; + } + // At this point, we know if SOME version is downloaded and is valid, but we don't know if it's the latest + const latestReleaseInfo = await AddLib.getLatestAddLibReleaseInfo(); + if (!latestReleaseInfo) { + return AddressLibDownloadedState.downloadedButCantCheckForUpdates; + } + const latestAssetList = AddLib.getAssetListFromAddLibRelease(latestReleaseInfo); + if (!latestAssetList) { + return AddressLibDownloadedState.downloadedButCantCheckForUpdates; + } + if (latestAssetList.version != assetList.version) { + return AddressLibDownloadedState.outdated; + } + return AddressLibDownloadedState.latest; + } + + /** + * Right now, this just checks if the address libraries are installed or not + * It returns either "Installed" or "Not Installed". + * In the future, we might check if the installed address libraries are outdated or not + * @param game + * @param modsDir + * @returns + */ + public async getInstallState(game: PapyrusGame, modsDir?: string): Promise { + const ModsInstallDir = modsDir || (await this._pathResolver.getModParentPath(game)) || ''; + if (!ModsInstallDir || ModsInstallDir.length === 0) { + return AddressLibInstalledState.notInstalled; + } + const state = await AddLib._checkAddressLibsInstalled(game, ModsInstallDir); + if (state === AddressLibInstalledState.notInstalled) { + return AddressLibInstalledState.notInstalled; + } + + // At this point, we know if the address libraries are installed or not, but we don't know if they're outdated + // TODO: For right now, we're not going to attempt to update the address libraries if they're outdated + // We will have to have to ensure that the repo that we are using is always up-to-date before we start doing this + return state; + + const downloadedState = await this.getDownloadedState(); + // We don't check the installed address lib versions if we don't have the latest version downloaded + if (downloadedState !== AddressLibDownloadedState.latest) { + return AddressLibInstalledState.installedButCantCheckForUpdates; + } + const assetList = await this.getCurrentDownloadedAssetList(); + if (!assetList) { + return AddressLibInstalledState.installedButCantCheckForUpdates; + } + return await AddLib._checkAddressLibsInstalled(game, ModsInstallDir, assetList); + } + + public async installLibraries( + game: PapyrusGame, + forceDownload: boolean = false, + cancellationToken = new CancellationTokenSource().token, + modsDir: string | undefined + ): Promise { + const ParentInstallDir = modsDir || (await this._pathResolver.getModParentPath(game)); + if (!ParentInstallDir) { + return false; + } + const addressLibDownloadPath = await this._pathResolver.getAddressLibraryDownloadFolder(); + const downloadedState = await this.getDownloadedState(); + if (downloadedState === AddressLibDownloadedState.notDownloaded) { + if (forceDownload) { + if ((await this.DownloadLatestAddressLibs(cancellationToken)) != DownloadResult.success) { + return false; + } + } else { + return false; + } + } + const assetList = await this.getCurrentDownloadedAssetList(); + + if (!assetList) { + return false; + } + return AddLib._installAddressLibs(game, ParentInstallDir, addressLibDownloadPath, assetList, cancellationToken); + } +} + +export const IAddressLibraryInstallService: interfaces.ServiceIdentifier = + Symbol('addressLibraryInstallService'); diff --git a/src/papyrus-lang-vscode/src/debugger/DebugLauncherService.ts b/src/papyrus-lang-vscode/src/debugger/DebugLauncherService.ts new file mode 100644 index 0000000..ead5e33 --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/DebugLauncherService.ts @@ -0,0 +1,177 @@ +import { inject, injectable, interfaces } from 'inversify'; +import { IExtensionConfigProvider } from '../ExtensionConfigProvider'; +import { CancellationToken, CancellationTokenSource, window } from 'vscode'; +import { IPathResolver } from '../common/PathResolver'; +import { PapyrusGame } from '../PapyrusGame'; +import { ILanguageClientManager } from '../server/LanguageClientManager'; +import { getGameIsRunning, getGamePIDs } from '../Utilities'; +import { ChildProcess, spawn } from 'node:child_process'; + +export enum DebugLaunchState { + success, + launcherError, + gameFailedToStart, + gameExitedBeforeOpening, + multipleGamesRunning, + cancelled, +} +export interface IDebugLauncherService { + tearDownAfterDebug(): Promise; + runLauncher( + launcherCommand: LaunchCommand, + game: PapyrusGame, + portToCheck: number, + cancellationToken?: CancellationToken + ): Promise; +} + +export interface LaunchCommand { + command: string; + args: string[]; +} + +@injectable() +export class DebugLauncherService implements IDebugLauncherService { + private readonly _configProvider: IExtensionConfigProvider; + private readonly _languageClientManager: ILanguageClientManager; + private readonly _pathResolver: IPathResolver; + + // TODO: Move this stuff into the global Context + private cancellationTokenSource: CancellationTokenSource | undefined; + private launcherProcess: ChildProcess | undefined; + private gamePID: number | undefined; + private currentGame: PapyrusGame | undefined; + constructor( + @inject(ILanguageClientManager) languageClientManager: ILanguageClientManager, + @inject(IExtensionConfigProvider) configProvider: IExtensionConfigProvider, + @inject(IPathResolver) pathResolver: IPathResolver + ) { + this._languageClientManager = languageClientManager; + this._configProvider = configProvider; + this._pathResolver = pathResolver; + } + + async tearDownAfterDebug() { + // If MO2 was already opened by the user before launch, the process would have detached and this will be closed anyway + if (this.launcherProcess) { + this.launcherProcess.removeAllListeners(); + this.launcherProcess.kill(); + } + if (this.gamePID && this.currentGame && (await getGameIsRunning(this.currentGame))) { + process.kill(this.gamePID); + } + this.launcherProcess = undefined; + this.gamePID = undefined; + this.currentGame = undefined; + return true; + } + + async keepSleepingUntil(startTime: number, timeout: number) { + const currentTime = new Date().getTime(); + + if (currentTime > startTime + timeout) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + return true; + } + + async cancelLaunch() { + if (this.cancellationTokenSource) { + this.cancellationTokenSource.cancel(); + } + } + + async runLauncher( + launcherCommand: LaunchCommand, + game: PapyrusGame, + portToCheck: number, + cancellationToken: CancellationToken | undefined + ): Promise { + await this.tearDownAfterDebug(); + if (!cancellationToken) { + this.cancellationTokenSource = new CancellationTokenSource(); + cancellationToken = this.cancellationTokenSource.token; + } + this.currentGame = game; + const cmd = launcherCommand.command; + const args = launcherCommand.args; + let _stdOut: string = ''; + let _stdErr: string = ''; + this.launcherProcess = spawn(cmd, args); + if (!this.launcherProcess || !this.launcherProcess.stdout || !this.launcherProcess.stderr) { + window.showErrorMessage(`Failed to start launcher process.\ncmd: ${cmd}\nargs: ${args.join(' ')}`); + return DebugLaunchState.launcherError; + } + this.launcherProcess.stdout.on('data', (data) => { + _stdOut += data; + }); + this.launcherProcess.stderr.on('data', (data) => { + _stdErr += data; + }); + const GameStartTimeout = 15000; + // get the current system time + let startTime = new Date().getTime(); + // wait for the games process to start + while (!cancellationToken.isCancellationRequested) { + if (!(await getGameIsRunning(game)) && (await this.keepSleepingUntil(startTime, GameStartTimeout))) { + // check if the launcher process failed to launch, or exited and returned an error + if ( + !this.launcherProcess || + (this.launcherProcess.exitCode !== null && this.launcherProcess.exitCode !== 0) + ) { + window.showErrorMessage( + `Launcher process exited with error code ${ + this.launcherProcess.exitCode + }.\ncmd: ${cmd}\nargs: ${args.join(' ')}\nstderr: ${_stdErr}\nstdout: ${_stdOut}` + ); + return DebugLaunchState.launcherError; + } + } else { + break; + } + } + + if (cancellationToken.isCancellationRequested) { + await this.tearDownAfterDebug(); + return DebugLaunchState.cancelled; + } + // we can't get the PID of the game from the launcher process because + // both MO2 and the script extender loaders fork and deatch the game process + const gamePIDs = await getGamePIDs(game); + + if (gamePIDs.length === 0) { + return DebugLaunchState.gameFailedToStart; + } + + if (gamePIDs.length > 1) { + return DebugLaunchState.multipleGamesRunning; + } + this.gamePID = gamePIDs[0]; + + // TODO: REMOVE THIS SHIT WHEN WE YEET THE DEBUGADAPTERPROXY + startTime = new Date().getTime(); + + // wait for the game to fully load + let waitedForGame = false; + while (!cancellationToken.isCancellationRequested) { + if (await this.keepSleepingUntil(startTime, GameStartTimeout)) { + if (!(await getGameIsRunning(game))) { + return DebugLaunchState.gameExitedBeforeOpening; + } + } else { + waitedForGame = true; + break; + } + } + if (!waitedForGame && cancellationToken.isCancellationRequested) { + await this.tearDownAfterDebug(); + return DebugLaunchState.cancelled; + } + + return DebugLaunchState.success; + } +} + +export const IDebugLauncherService: interfaces.ServiceIdentifier = + Symbol('DebugLauncherService'); diff --git a/src/papyrus-lang-vscode/src/debugger/DebugSupportInstallService.ts b/src/papyrus-lang-vscode/src/debugger/DebugSupportInstallService.ts index a07d850..c745491 100644 --- a/src/papyrus-lang-vscode/src/debugger/DebugSupportInstallService.ts +++ b/src/papyrus-lang-vscode/src/debugger/DebugSupportInstallService.ts @@ -2,7 +2,7 @@ import { inject, injectable, interfaces } from 'inversify'; import { IExtensionConfigProvider } from '../ExtensionConfigProvider'; import { CancellationToken, CancellationTokenSource } from 'vscode'; import { take } from 'rxjs/operators'; -import { IPathResolver } from '../common/PathResolver'; +import { getPluginDllName, IPathResolver, PathResolver } from '../common/PathResolver'; import { PapyrusGame } from '../PapyrusGame'; import { ILanguageClientManager } from '../server/LanguageClientManager'; import { ClientHostStatus } from '../server/LanguageClientHost'; @@ -13,7 +13,7 @@ import * as fs from 'fs'; import { promisify } from 'util'; import md5File from 'md5-file'; - +import { PDSModName } from '../common/constants'; const exists = promisify(fs.exists); const copyFile = promisify(fs.copyFile); @@ -27,8 +27,8 @@ export enum DebugSupportInstallState { } export interface IDebugSupportInstallService { - getInstallState(game: PapyrusGame): Promise; - installPlugin(game: PapyrusGame, cancellationToken?: CancellationToken): Promise; + getInstallState(game: PapyrusGame, modsDir?: string): Promise; + installPlugin(game: PapyrusGame, cancellationToken?: CancellationToken, modsDir?: string): Promise; } @injectable() @@ -46,20 +46,29 @@ export class DebugSupportInstallService implements IDebugSupportInstallService { this._configProvider = configProvider; this._pathResolver = pathResolver; } - - async getInstallState(game: PapyrusGame): Promise { + private _getMMPluginInstallPath(game: PapyrusGame, modsDir: string): string { + return path.join( + modsDir, + PDSModName, + PathResolver._getModMgrExtenderPluginRelativePath(game), + getPluginDllName(game, false) + ); + } + // TODO: Refactor this properly, right now it's just hacked to work with MO2LaunchDescriptor + async getInstallState(game: PapyrusGame, modsDir: string | undefined): Promise { const config = (await this._configProvider.config.pipe(take(1)).toPromise())[game]; const client = await this._languageClientManager.getLanguageClientHost(game); const status = await client.status.pipe(take(1)).toPromise(); - - if (status === ClientHostStatus.disabled) { - return DebugSupportInstallState.gameDisabled; + // We bypass these checks if we were given a mods directory, as that means we're in a mod manager. + if (!modsDir) { + if (status === ClientHostStatus.disabled) { + return DebugSupportInstallState.gameDisabled; + } + + if (status === ClientHostStatus.missing) { + return DebugSupportInstallState.gameMissing; + } } - - if (status === ClientHostStatus.missing) { - return DebugSupportInstallState.gameMissing; - } - const bundledPluginPath = await this._pathResolver.getDebugPluginBundledPath(game); // If the debugger plugin isn't bundled, we'll assume this is in-development. // TODO: Figure out if this is how it should still be done. Can figure that out once we start doing release @@ -68,7 +77,9 @@ export class DebugSupportInstallService implements IDebugSupportInstallService { return DebugSupportInstallState.installed; } - const installedPluginPath = await this._pathResolver.getDebugPluginInstallPath(game, false); + const installedPluginPath = modsDir + ? this._getMMPluginInstallPath(game, modsDir) + : await this._pathResolver.getDebugPluginInstallPath(game, false); if (!installedPluginPath || !(await exists(installedPluginPath))) { return DebugSupportInstallState.notInstalled; } @@ -80,15 +91,22 @@ export class DebugSupportInstallService implements IDebugSupportInstallService { return DebugSupportInstallState.incorrectVersion; } - if (config.modDirectoryPath) { + if (config.modDirectoryPath || modsDir) { return DebugSupportInstallState.installedAsMod; } return DebugSupportInstallState.installed; } - async installPlugin(game: PapyrusGame, cancellationToken = new CancellationTokenSource().token): Promise { - const pluginInstallPath = await this._pathResolver.getDebugPluginInstallPath(game, false); + // TODO: Refactor this properly, right now it's just hacked to work with MO2LaunchDescriptor + async installPlugin( + game: PapyrusGame, + cancellationToken = new CancellationTokenSource().token, + modsDir: string | undefined + ): Promise { + const pluginInstallPath = modsDir + ? this._getMMPluginInstallPath(game, modsDir) + : await this._pathResolver.getDebugPluginInstallPath(game, false); if (!pluginInstallPath) { return false; } diff --git a/src/papyrus-lang-vscode/src/debugger/GameDebugConfiguratorService.ts b/src/papyrus-lang-vscode/src/debugger/GameDebugConfiguratorService.ts new file mode 100644 index 0000000..dbb5adb --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/GameDebugConfiguratorService.ts @@ -0,0 +1,96 @@ +// TODO: Remove, no longer necessary + +import { inject, injectable, interfaces } from 'inversify'; +import { IExtensionConfigProvider } from '../ExtensionConfigProvider'; +import { take } from 'rxjs/operators'; +import { IPathResolver } from '../common/PathResolver'; +import { PapyrusGame, getGameIniName } from '../PapyrusGame'; +import { ILanguageClientManager } from '../server/LanguageClientManager'; +import { ClientHostStatus } from '../server/LanguageClientHost'; +import { CheckIfDebuggingIsEnabledInIni, TurnOnDebuggingInIni } from '../common/GameHelpers'; +import { WriteChangesToIni, ParseIniFile } from '../common/INIHelpers'; + +import * as path from 'path'; +import * as fs from 'fs'; +import { promisify } from 'util'; + +const exists = promisify(fs.exists); + +export enum GameDebugConfigurationState { + debugEnabled, + debugNotEnabled, + gameIniMissing, + gameUserDirMissing, + gameMissing, + gameDisabled, +} + +export interface IGameDebugConfiguratorService { + getState(game: PapyrusGame, gameUserDir?: string): Promise; + configureDebug(game: PapyrusGame, gameUserDir?: string): Promise; +} +@injectable() +export class GameDebugConfiguratorService implements IGameDebugConfiguratorService { + private readonly _configProvider: IExtensionConfigProvider; + private readonly _languageClientManager: ILanguageClientManager; + private readonly _pathResolver: IPathResolver; + + constructor( + @inject(ILanguageClientManager) languageClientManager: ILanguageClientManager, + @inject(IExtensionConfigProvider) configProvider: IExtensionConfigProvider, + @inject(IPathResolver) pathResolver: IPathResolver + ) { + this._languageClientManager = languageClientManager; + this._configProvider = configProvider; + this._pathResolver = pathResolver; + } + + async getState(game: PapyrusGame, gameUserDir: string | undefined): Promise { + const client = await this._languageClientManager.getLanguageClientHost(game); + const status = await client.status.pipe(take(1)).toPromise(); + if (!gameUserDir) { + if (status === ClientHostStatus.disabled) { + return GameDebugConfigurationState.gameDisabled; + } + if (status === ClientHostStatus.missing) { + return GameDebugConfigurationState.gameMissing; + } + } + const gameUserDirPath = gameUserDir || (await this._pathResolver.getUserGamePath(game)); + if (!gameUserDirPath) { + return GameDebugConfigurationState.gameUserDirMissing; + } + const gameIniPath = path.join(gameUserDirPath, getGameIniName(game)); + if (!(await exists(gameIniPath))) { + return GameDebugConfigurationState.gameIniMissing; + } + const inidata = await ParseIniFile(gameIniPath); + if (!inidata) { + return GameDebugConfigurationState.gameIniMissing; + } + if (!CheckIfDebuggingIsEnabledInIni(inidata)) { + return GameDebugConfigurationState.debugNotEnabled; + } + return GameDebugConfigurationState.debugEnabled; + } + + async configureDebug(game: PapyrusGame, gameUserDir: string | undefined): Promise { + const gameUserDirPath = gameUserDir || (await this._pathResolver.getUserGamePath(game)); + if (!gameUserDirPath) { + return false; + } + const gameIniPath = path.join(gameUserDirPath, getGameIniName(game)); + if (!(await exists(gameIniPath))) { + return false; + } + const inidata = await ParseIniFile(gameIniPath); + if (!inidata) { + return false; + } + const newinidata = TurnOnDebuggingInIni(inidata); + return await WriteChangesToIni(gameIniPath, newinidata); + } +} + +export const IGameDebugConfiguratorService: interfaces.ServiceIdentifier = + Symbol('GameDebugConfiguratorService'); diff --git a/src/papyrus-lang-vscode/src/debugger/MO2ConfiguratorService.ts b/src/papyrus-lang-vscode/src/debugger/MO2ConfiguratorService.ts new file mode 100644 index 0000000..aebb75d --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/MO2ConfiguratorService.ts @@ -0,0 +1,293 @@ +import { inject, injectable, interfaces } from 'inversify'; +import { IExtensionConfigProvider } from '../ExtensionConfigProvider'; +import { take } from 'rxjs/operators'; +import { IPathResolver } from '../common/PathResolver'; +import { ILanguageClientManager } from '../server/LanguageClientManager'; + +import { IDebugSupportInstallService, DebugSupportInstallState } from './DebugSupportInstallService'; +import { IAddressLibraryInstallService, AddressLibInstalledState } from './AddressLibInstallService'; +import { MO2LauncherDescriptor } from './MO2LaunchDescriptorFactory'; +import { + AddRequiredModsToModList, + checkAddressLibrariesExistAndEnabled, + checkPDSModExistsAndEnabled, + isMO2Running, + isOurMO2Running, + killAllMO2Processes, +} from './MO2Helpers'; +import * as MO2Lib from '../common/MO2Lib'; +import { CancellationTokenSource } from 'vscode-languageclient'; +import { CancellationToken } from 'vscode'; +import { spawn } from 'child_process'; + +export enum MO2LaunchConfigurationStatus { + Ready = 0, + // fixable + PDSNotInstalled = 1 << 0, + PDSIncorrectVersion = 1 << 1, + AddressLibraryNotInstalled = 1 << 2, + AddressLibraryOutdated = 1 << 3, // This is not currently in use + PDSModNotEnabledInModList = 1 << 4, + AddressLibraryModNotEnabledInModList = 1 << 5, + // not fixable + ModListNotParsable = 1 << 6, + UnknownError = 1 << 7, +} + +export interface IMO2ConfiguratorService { + getStateFromConfig(launchDescriptor: MO2LauncherDescriptor): Promise; + fixDebuggerConfiguration( + launchDescriptor: MO2LauncherDescriptor, + cancellationToken?: CancellationToken + ): Promise; +} +function _getErrorMessage(state: MO2LaunchConfigurationStatus) { + switch (state) { + case MO2LaunchConfigurationStatus.Ready: + return 'Ready'; + case MO2LaunchConfigurationStatus.PDSNotInstalled: + return 'Papyrus Debug Support is not installed'; + case MO2LaunchConfigurationStatus.PDSIncorrectVersion: + return 'Papyrus Debug Support is not the correct version'; + case MO2LaunchConfigurationStatus.AddressLibraryNotInstalled: + return 'Address Library is not installed'; + case MO2LaunchConfigurationStatus.AddressLibraryOutdated: // This is not currently in use + return 'Address Library is not the correct version'; + case MO2LaunchConfigurationStatus.PDSModNotEnabledInModList: + return 'Papyrus Debug Support mod is not enabled in the mod list'; + case MO2LaunchConfigurationStatus.AddressLibraryModNotEnabledInModList: + return 'Address Library mod is not enabled in the mod list'; + case MO2LaunchConfigurationStatus.ModListNotParsable: + return 'Mod list is not parsable'; + case MO2LaunchConfigurationStatus.UnknownError: + return 'An unknown error'; + } + return 'An unknown error'; +} + +export function GetErrorMessageFromStatus(state: MO2LaunchConfigurationStatus): string { + const errorMessages = new Array(); + const states = getStates(state); + if (states.length === 1 && states[0] === MO2LaunchConfigurationStatus.Ready) { + return 'Ready'; + } + for (const state of states) { + errorMessages.push(_getErrorMessage(state)); + } + const errMsg = '- ' + errorMessages.join('\n - '); + return errMsg; +} +function getStates(state: MO2LaunchConfigurationStatus): MO2LaunchConfigurationStatus[] { + if (state === MO2LaunchConfigurationStatus.Ready) { + return [MO2LaunchConfigurationStatus.Ready]; + } + const states: MO2LaunchConfigurationStatus[] = []; + let key: keyof typeof MO2LaunchConfigurationStatus; + for (key in MO2LaunchConfigurationStatus) { + const value: MO2LaunchConfigurationStatus = Number(MO2LaunchConfigurationStatus[key]); + if (state & value) { + states.push(value); + } + } + return states; +} + +@injectable() +export class MO2ConfiguratorService implements IMO2ConfiguratorService { + private readonly _configProvider: IExtensionConfigProvider; + private readonly _languageClientManager: ILanguageClientManager; + private readonly _pathResolver: IPathResolver; + private readonly _debugSupportInstallService: IDebugSupportInstallService; + private readonly _addressLibraryInstallService: IAddressLibraryInstallService; + + constructor( + @inject(ILanguageClientManager) languageClientManager: ILanguageClientManager, + @inject(IExtensionConfigProvider) configProvider: IExtensionConfigProvider, + @inject(IPathResolver) pathResolver: IPathResolver, + @inject(IDebugSupportInstallService) debugSupportInstallService: IDebugSupportInstallService, + @inject(IAddressLibraryInstallService) addressLibraryInstallService: IAddressLibraryInstallService + ) { + this._languageClientManager = languageClientManager; + this._configProvider = configProvider; + this._pathResolver = pathResolver; + this._debugSupportInstallService = debugSupportInstallService; + this._addressLibraryInstallService = addressLibraryInstallService; + } + public static errorIsRecoverable(state: MO2LaunchConfigurationStatus): boolean { + if (state === MO2LaunchConfigurationStatus.Ready) { + return true; + } + if ( + state & MO2LaunchConfigurationStatus.ModListNotParsable || + state & MO2LaunchConfigurationStatus.UnknownError + ) { + return false; + } + return true; + } + + public async getStateFromConfig(launchDescriptor: MO2LauncherDescriptor): Promise { + let state = MO2LaunchConfigurationStatus.Ready; + state |= await this.checkPDSisPresent(launchDescriptor); + state |= await this.checkAddressLibsArePresent(launchDescriptor); + state |= await this.checkModListHasPDSEnabled(launchDescriptor); + state |= await this.checkModListHasAddressLibsEnabled(launchDescriptor); + return state; + } + + private async checkPDSisPresent(launchDescriptor: MO2LauncherDescriptor): Promise { + const result = await this._debugSupportInstallService.getInstallState( + launchDescriptor.game, + launchDescriptor.instanceInfo.modsFolder + ); + const ignoreVersion = (await this._configProvider.config.pipe(take(1)).toPromise())[launchDescriptor.game] + .ignoreDebuggerVersion; + if (result !== DebugSupportInstallState.installed && result !== DebugSupportInstallState.installedAsMod) { + if (result === DebugSupportInstallState.incorrectVersion) { + if (ignoreVersion) { + return MO2LaunchConfigurationStatus.Ready; + } + return MO2LaunchConfigurationStatus.PDSIncorrectVersion; + } + // TODO: care about the other states? + return MO2LaunchConfigurationStatus.PDSNotInstalled; + } + return MO2LaunchConfigurationStatus.Ready; + } + + private async checkAddressLibsArePresent( + launchDescriptor: MO2LauncherDescriptor + ): Promise { + const result = await this._addressLibraryInstallService.getInstallState( + launchDescriptor.game, + launchDescriptor.instanceInfo.modsFolder + ); + if (result === AddressLibInstalledState.notInstalled) { + return MO2LaunchConfigurationStatus.AddressLibraryNotInstalled; + } else if (result === AddressLibInstalledState.outdated) { + // not currently in use + return MO2LaunchConfigurationStatus.AddressLibraryOutdated; + } // we don't care about installedButCantCheckForUpdates + return MO2LaunchConfigurationStatus.Ready; + } + + // Check if the MO2 modlist has the PDS mod and the Address Library mod enabled + private async checkModListHasPDSEnabled( + launchDescriptor: MO2LauncherDescriptor + ): Promise { + const modList = await MO2Lib.ParseModListFile(launchDescriptor.profileToLaunchData.modListPath); + // The descriptor factory checked the path and the data was parsable, so this should never happen + if (!modList) { + return MO2LaunchConfigurationStatus.ModListNotParsable; + } + + if (!checkPDSModExistsAndEnabled(modList)) { + return MO2LaunchConfigurationStatus.PDSModNotEnabledInModList; + } + return MO2LaunchConfigurationStatus.Ready; + } + + private async checkModListHasAddressLibsEnabled( + launchDescriptor: MO2LauncherDescriptor + ): Promise { + const modList = await MO2Lib.ParseModListFile(launchDescriptor.profileToLaunchData.modListPath); + // The descriptor factory checked the path and the data was parsable, so this should never happen + if (!modList) { + return MO2LaunchConfigurationStatus.ModListNotParsable; + } + if (!checkAddressLibrariesExistAndEnabled(modList, launchDescriptor.game)) { + return MO2LaunchConfigurationStatus.AddressLibraryModNotEnabledInModList; + } + + return MO2LaunchConfigurationStatus.Ready; + } + + public async fixDebuggerConfiguration( + launchDescriptor: MO2LauncherDescriptor, + cancellationToken = new CancellationTokenSource().token + ): Promise { + const states = getStates(await this.getStateFromConfig(launchDescriptor)); + for (const state of states) { + switch (state) { + case MO2LaunchConfigurationStatus.Ready: + break; + case MO2LaunchConfigurationStatus.PDSNotInstalled: + case MO2LaunchConfigurationStatus.PDSIncorrectVersion: + if ( + !(await this._debugSupportInstallService.installPlugin( + launchDescriptor.game, + cancellationToken, + launchDescriptor.instanceInfo.modsFolder + )) + ) { + return false; + } + break; + case MO2LaunchConfigurationStatus.AddressLibraryNotInstalled: + case MO2LaunchConfigurationStatus.AddressLibraryOutdated: + if ( + !(await this._addressLibraryInstallService.installLibraries( + launchDescriptor.game, + true, // force download + cancellationToken, + launchDescriptor.instanceInfo.modsFolder + )) + ) { + return false; + } + break; + case MO2LaunchConfigurationStatus.PDSModNotEnabledInModList: + case MO2LaunchConfigurationStatus.AddressLibraryModNotEnabledInModList: { + let wasRunning = false; + // if MO2 is running, we have to force a refresh after we add the mods, or it will overwrite our changes + if (await isMO2Running()) { + wasRunning = true; + // if ModOrganizer is currently running, and the installation or selected profile isn't what we're going to run, this will fuck up, kill it + const notOurs = !(await isOurMO2Running(launchDescriptor.MO2EXEPath)); + if ( + notOurs || + launchDescriptor.instanceInfo.selectedProfile !== launchDescriptor.profileToLaunchData.name + ) { + await killAllMO2Processes(); + } + } + + const modList = await MO2Lib.ParseModListFile(launchDescriptor.profileToLaunchData.modListPath); + if (!modList) { + return false; + } + + const newmodList = AddRequiredModsToModList(modList, launchDescriptor.game); + if ( + !MO2Lib.WriteChangesToModListFile(launchDescriptor.profileToLaunchData.modListPath, newmodList) + ) { + return false; + } + if (wasRunning) { + spawn( + launchDescriptor.MO2EXEPath, + ['-p', launchDescriptor.profileToLaunchData.name, 'refresh'], + { + detached: true, + stdio: 'ignore', + } + ).unref(); + } + + break; + } + default: + // shouldn't reach here + throw new Error(`Unknown state in fixDebuggerConfiguration`); + } + if (state === MO2LaunchConfigurationStatus.Ready) { + break; + } + } + + return true; + } +} + +export const IMO2ConfiguratorService: interfaces.ServiceIdentifier = + Symbol('mo2ConfiguratorService'); diff --git a/src/papyrus-lang-vscode/src/debugger/MO2Helpers.ts b/src/papyrus-lang-vscode/src/debugger/MO2Helpers.ts new file mode 100644 index 0000000..43c5325 --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/MO2Helpers.ts @@ -0,0 +1,225 @@ +import { existsSync } from 'fs'; +import path from 'path'; +import { getGameIniName } from '../PapyrusGame'; +import { PapyrusGame } from '../PapyrusGame'; +import { PDSModName } from '../common/constants'; +import { DetermineGameVariant, FindUserGamePath, getAddressLibNames } from '../common/GameHelpers'; +import { getEnvFromProcess, getGamePIDs, getPIDforProcessName, getPIDsforFullPath } from '../Utilities'; +import * as MO2Lib from '../common/MO2Lib'; +import { INIData, ParseIniFile } from '../common/INIHelpers'; + +// ours +export const PapyrusMO2Ids: MO2Lib.MO2LongGameID[] = ['Fallout 4', 'Skyrim Special Edition', 'Skyrim']; + +export function GetMO2GameID(game: PapyrusGame): MO2Lib.MO2LongGameID { + switch (game) { + case PapyrusGame.fallout4: + return 'Fallout 4'; + case PapyrusGame.skyrimSpecialEdition: + return 'Skyrim Special Edition'; + case PapyrusGame.skyrim: + return 'Skyrim'; + } +} + +export function GetPapyrusGameFromMO2GameID(game: MO2Lib.MO2LongGameID): PapyrusGame | undefined { + switch (game) { + case 'Fallout 4': + return PapyrusGame.fallout4; + case 'Skyrim Special Edition': + return PapyrusGame.skyrimSpecialEdition; + case 'Skyrim': + return PapyrusGame.skyrim; + } + return undefined; +} + +export function checkPDSModExistsAndEnabled(modlist: Array) { + return MO2Lib.checkIfModExistsAndEnabled(modlist, PDSModName); +} + +export function checkAddressLibrariesExistAndEnabled(modlist: Array, game: PapyrusGame) { + const names = getAddressLibNames(game); + for (const name of names) { + if (!MO2Lib.checkIfModExistsAndEnabled(modlist, name)) { + return false; + } + } + return true; +} + +export function AddRequiredModsToModList(p_modlist: Array, game: PapyrusGame) { + // add the debug adapter to the modlist + let modlist = p_modlist; + const addlibsNeeded = !checkAddressLibrariesExistAndEnabled(modlist, game); + const pdsNeeded = !checkPDSModExistsAndEnabled(modlist); + if (addlibsNeeded || pdsNeeded) { + if (pdsNeeded) { + modlist = MO2Lib.AddOrEnableModInModList(modlist, PDSModName); + } + if (addlibsNeeded) { + const addressLibraryMods = getAddressLibNames(game).map( + (d) => new MO2Lib.ModListItem(d, MO2Lib.ModEnabledState.enabled) + ); + modlist = addressLibraryMods.reduce( + (_modlist, mod) => MO2Lib.AddOrEnableModInModList(_modlist, mod.name), + modlist + ); + } + } + return modlist; +} + +export function GetMO2GameShortIdentifier(game: PapyrusGame): string { + const gamestring = + game === PapyrusGame.skyrimSpecialEdition ? 'skyrimse' : PapyrusGame[game].toLowerCase().replace(/ /g, ''); + return gamestring; +} + +/** + * Checks if the game was launched with MO2 + * + * ModOrganizer launches the game with a modified PATH variable, which is used to load the dlls from the MO2 folder + * We check for the existence of this PATH component and if it points to the MO2 folder + * @param game + * @returns boolean + */ +export async function WasGameLaunchedWithMO2(game: PapyrusGame) { + // get GamePID + const pids = await getGamePIDs(game); + if (!pids || pids.length === 0) { + return false; + } + const pid = pids[0]; + // get env from process + const otherEnv = await getEnvFromProcess(pid); + if (!otherEnv) { + return false; + } + const pathVar: string = otherEnv['Path']; + if (!pathVar) { + return false; + } + const pathVarSplit = pathVar.split(';'); + if (pathVarSplit.length === 0 || !pathVarSplit[0]) { + return false; + } + const firstPath = path.normalize(pathVarSplit[0]); + if (!firstPath) { + return false; + } + const basename = path.basename(firstPath); + if (basename.toLowerCase() === 'dlls') { + const parentdir = path.dirname(firstPath); + const MO2EXEPath = path.join(parentdir, MO2Lib.MO2EXEName); + if (existsSync(MO2EXEPath)) { + return true; + } + } + return false; +} + +export async function GetPossibleMO2InstancesForModFolder( + modsFolder: string, + game: PapyrusGame +): Promise { + const gameId = GetMO2GameID(game); + const instances = (await MO2Lib.FindAllKnownMO2EXEandInstanceLocations(gameId)).reduce((acc, val) => { + // Combine all the instances together, check to see if the mods folder is in the instance + return acc.concat(val.instances.filter((d) => d.modsFolder === modsFolder)); + }, [] as Array); + if (instances.length === 0) { + return undefined; + } + //filter out the dupes from instances by comparing iniPaths + const filteredInstances = instances.filter((d, i) => { + return instances.findIndex((e) => e.iniPath === d.iniPath) === i; + }); + return filteredInstances; +} + +export async function getGameINIFromMO2Profile( + game: PapyrusGame, + gamePath: string, + profileFolder: string +): Promise { + // Game ini paths for MO2 are different depending on whether the profile has local settings or not + // if [General] LocalSettings=false, then the game ini is in the global game save folder + // if [General] LocalSettings=true, then the game ini is in the profile folder + + const settingsFile = path.join(profileFolder, 'settings.ini'); + const settingsIniData = await getMO2ProfileSettingsData(settingsFile); + if (!settingsIniData) { + throw new Error(`Could not get settings ini data`); + } + const gameIniName = getGameIniName(game); + let gameIniPath: string; + if (settingsIniData.General.LocalSettings === false) { + // We don't have local game ini settings, so we need to use the global ones + const variant = await DetermineGameVariant(game, gamePath); + const gameSaveDir = await FindUserGamePath(game, variant); + if (!gameSaveDir || !existsSync(gameSaveDir)) { + throw new Error( + `MO2 profile does not have local game INI settings, but could not find the global game save directory at ${gameSaveDir} (Try running the game once to generate the ini file)` + ); + } + gameIniPath = path.join(gameSaveDir, gameIniName); + if (!existsSync(gameIniPath)) { + throw new Error( + `MO2 profile does not have local game INI settings, but could not find the global game ${game} ini @ ${gameIniPath} (Try running the game once to generate the ini file)` + ); + } + } else { + gameIniPath = path.join(profileFolder, gameIniName); + // TODO: This is fixable by running `ModOrganizer.exe refresh` + if (!existsSync(gameIniPath)) { + throw new Error( + `MO2 profile has local game INI settings, but could not find the local ${game} ini @ ${gameIniPath}` + ); + } + } + + // We don't save this here, we just use it to check if the game ini is parsable + const gameIniData = await ParseIniFile(gameIniPath); + if (!gameIniData) { + throw new Error(`Game ini file is not parsable, try re-running the game to re-generate the ini file`); + } + return gameIniPath; +} + +export async function getMO2ProfileSettingsData(settingsIniPath: string): Promise { + const settingsIniData = await ParseIniFile(settingsIniPath); + if ( + !settingsIniData || + settingsIniData.General === undefined || + settingsIniData.General.LocalSettings === undefined + ) { + throw new Error(`MO2 profile Settings ini file ${settingsIniPath} is not parsable`); + } + return settingsIniData; +} + +export async function isMO2Running() { + return (await getPIDforProcessName(MO2Lib.MO2EXEName)).length > 0; +} +export async function isMO2ButNotThisOneRunning(MO2EXEPath: string) { + const pids = await getPIDforProcessName(MO2Lib.MO2EXEName); + if (pids.length === 0) { + return false; + } + const ourPids = await getPIDsforFullPath(MO2EXEPath); + if (ourPids.length === 0) { + return true; + } + return pids.some((pid) => ourPids.indexOf(pid) === -1); +} +export async function isOurMO2Running(MO2EXEPath: string) { + return (await getPIDsforFullPath(MO2EXEPath)).length > 0; +} + +export async function killAllMO2Processes() { + const pids = await getPIDforProcessName(MO2Lib.MO2EXEName); + if (pids.length > 0) { + pids.map((pid) => process.kill(pid)); + } +} diff --git a/src/papyrus-lang-vscode/src/debugger/MO2LaunchDescriptorFactory.ts b/src/papyrus-lang-vscode/src/debugger/MO2LaunchDescriptorFactory.ts new file mode 100644 index 0000000..f88b9b4 --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/MO2LaunchDescriptorFactory.ts @@ -0,0 +1,239 @@ +import { existsSync, statSync } from 'fs'; +import { PapyrusGame } from '../PapyrusGame'; +import { MO2Config } from './PapyrusDebugSession'; + +import { injectable, interfaces } from 'inversify'; +import path from 'path'; +import { GetPapyrusGameFromMO2GameID } from './MO2Helpers'; +import * as MO2Lib from '../common/MO2Lib'; +import { LaunchCommand } from './DebugLauncherService'; + +export interface MO2ProfileData { + name: string; + folderPath: string; + /** + * Path to the ini file that contains the settings for this profile + * Should always be present in profile folder and should always be named "settings.ini" + * @type {string} + */ + settingsIniPath: string; + /** + * Path to the txt file that contains the mod list for this profile + * Should always be present in profile folder and should always be named "modlist.txt" + * @type {string} + */ + modListPath: string; + /** + * Path to the ini file that contains the Skyrim or Fallout 4 settings. + * Depending if the profile has local settings, this is either present in the profile folder or in the global save game folder. + * Should alays be named "Skyrim.ini" or "Fallout4.ini" + * @type {string} + */ + gameIniPath: string; +} + +export interface IMO2LaunchDescriptorFactory { + createMO2LaunchDecriptor( + launcherPath: string, + launcherArgs: string[], + mo2Config: MO2Config, + game: PapyrusGame + ): Promise; +} + +@injectable() +export class MO2LaunchDescriptorFactory implements IMO2LaunchDescriptorFactory { + constructor() {} + // TODO: After testing, make these private + public static async populateMO2ProfileData(name: string, profileFolder: string): Promise { + if (!existsSync(profileFolder)) { + throw new Error(`Invalid MO2 profile: Could not find the profile folder ${profileFolder}}`); + } + // This is the path to the ini file that contains the settings for this profile + // This should always be present; if it isn't, then something is wrong with the profile + const settingsIniPath = path.join(profileFolder, 'settings.ini'); + if (!existsSync(settingsIniPath)) { + throw new Error(`Invalid MO2 profile: Could not find the settings.ini file in ${profileFolder}}`); + } + const ModsListPath = path.join(profileFolder, 'modlist.txt'); + if (!existsSync(ModsListPath)) { + throw new Error(`Invalid MO2 profile: Could not find the modlist.txt file`); + } + const ModsListData = await MO2Lib.ParseModListFile(ModsListPath); + if (!ModsListData) { + throw new Error(`Invalid MO2 profile: Mod list file is not parsable`); + } + return { + name: name, + folderPath: profileFolder, + settingsIniPath: settingsIniPath, + modListPath: ModsListPath, + } as MO2ProfileData; + } + + public static async PopulateMO2InstanceData( + MO2EXEPath: string, + instanceName: string, + exeTitle: string, + game: PapyrusGame, + instanceINIPath?: string + ) { + let InstanceInfo: MO2Lib.MO2InstanceInfo | undefined; + if (!instanceINIPath) { + InstanceInfo = await MO2Lib.FindInstanceForEXE(MO2EXEPath, instanceName); + } else { + InstanceInfo = await MO2Lib.GetMO2InstanceInfo(instanceINIPath); + } + if (!InstanceInfo) { + throw new Error(`Could not find the instance '${instanceName}' for the MO2 installation at ${MO2EXEPath}`); + } + const papgame = GetPapyrusGameFromMO2GameID(InstanceInfo.gameName); + if (!papgame || papgame !== game) { + throw new Error(`Instance ${instanceName} is not for game ${game}`); + } + if (InstanceInfo.gameDirPath === undefined) { + throw new Error(`Instance ${instanceName} does not have a game directory path`); + } + if (!existsSync(InstanceInfo.profilesFolder) || !statSync(InstanceInfo.profilesFolder).isDirectory()) { + throw new Error(`Could not find the profiles folder for instance ${instanceName}`); + } + if (!existsSync(InstanceInfo.modsFolder) || !statSync(InstanceInfo.modsFolder).isDirectory()) { + throw new Error(`Could not find the mods folder for instance ${instanceName}`); + } + if (!InstanceInfo.customExecutables.filter((entry) => entry.title === exeTitle).length) { + throw new Error(`Instance ${instanceName} does not have an executable named ${exeTitle}`); + } + return InstanceInfo; + } + + public static async populateMO2LaunchConfiguration( + launcherPath: string, + launcherArgs: string[], + mo2Config: MO2Config, + game: PapyrusGame + ): Promise { + // taken care of by debug config provider + const { instanceName, exeName } = MO2Lib.parseMoshortcutURI(mo2Config.shortcutURI); + if (!instanceName || !exeName) { + throw new Error(`Could not parse the shortcut URI`); + } + + const MO2EXEPath = launcherPath; + if (!MO2EXEPath || !existsSync(MO2EXEPath) || !statSync(MO2EXEPath).isFile()) { + throw new Error(`Could not find the Mod Organizer 2 executable path`); + } + let instanceData: MO2Lib.MO2InstanceInfo; + try { + instanceData = await this.PopulateMO2InstanceData( + MO2EXEPath, + instanceName, + exeName, + game, + mo2Config.instanceIniPath + ); + } catch (error) { + throw new Error(`Could not populate the instance data: ${error}`); + } + const profile = mo2Config.profile || instanceData.selectedProfile; + if (!profile) { + throw new Error(`Could not find a profile to launch`); + } + // check if the instance is + const profilePath = path.join(instanceData.profilesFolder, profile); + // check if it exists and is directory + if (!existsSync(profilePath) || !statSync(profilePath).isDirectory()) { + throw new Error(`Could not find the profile '${profile}' in ${instanceData.profilesFolder}`); + } + const profileData: MO2ProfileData = await this.populateMO2ProfileData(profile, profilePath); + const additionalArgs = launcherArgs; + return { + exeTitle: exeName, + MO2EXEPath, + additionalArgs, + game, + instanceInfo: instanceData, + profileToLaunchData: profileData, + } as IMO2LauncherDescriptor; + } + + public async createMO2LaunchDecriptor( + launcherPath: string, + launcherArgs: string[], + mo2Config: MO2Config, + game: PapyrusGame + ): Promise { + if (!path.isAbsolute(launcherPath)) { + throw new Error(`The launcher path must be an absolute path`); + } + let idescriptor: IMO2LauncherDescriptor; + try { + idescriptor = await MO2LaunchDescriptorFactory.populateMO2LaunchConfiguration( + launcherPath, + launcherArgs, + mo2Config, + game + ); + } catch (error) { + throw new Error(`Could not create the launch configuration: ${error}`); + } + + return new MO2LauncherDescriptor(idescriptor); + } + dispose() {} +} + +export interface IMO2LauncherDescriptor { + exeTitle: string; + MO2EXEPath: string; + additionalArgs: string[]; + game: PapyrusGame; + instanceInfo: MO2Lib.MO2InstanceInfo; + profileToLaunchData: MO2ProfileData; + getLaunchCommand(): LaunchCommand; +} + +function joinArgs(args: string[]): string { + const _args = args; + for (const arg in args) { + if (_args[arg].includes(' ') && !_args[arg].startsWith('"') && !_args[arg].endsWith('"')) { + _args[arg] = `"${_args[arg]}"`; + } + } + return _args.join(' '); +} +export class MO2LauncherDescriptor implements IMO2LauncherDescriptor { + public readonly exeTitle: string = ''; + public readonly MO2EXEPath: string = ''; + public readonly additionalArgs: string[] = []; + public readonly game: PapyrusGame = PapyrusGame.skyrim; + public readonly instanceInfo: MO2Lib.MO2InstanceInfo = {} as MO2Lib.MO2InstanceInfo; + public readonly profileToLaunchData: MO2ProfileData = {} as MO2ProfileData; + + constructor(idecriptor: IMO2LauncherDescriptor) { + this.exeTitle = idecriptor.exeTitle; + this.MO2EXEPath = idecriptor.MO2EXEPath; + this.additionalArgs = idecriptor.additionalArgs; + this.game = idecriptor.game; + this.instanceInfo = idecriptor.instanceInfo; + this.profileToLaunchData = idecriptor.profileToLaunchData; + } + + public getLaunchCommand(): LaunchCommand { + const command = this.MO2EXEPath; + let cmdargs = ['-p', this.profileToLaunchData.name]; + if (this.instanceInfo.name !== 'portable') { + cmdargs = cmdargs.concat(['-i', this.instanceInfo.name]); + } + cmdargs = cmdargs.concat('run', '-e', this.exeTitle); + if (this.additionalArgs.length > 0) { + cmdargs = cmdargs.concat(['-a', joinArgs(this.additionalArgs)]); + } + return { + command: command, + args: cmdargs, + } as LaunchCommand; + } +} + +export const IMO2LaunchDescriptorFactory: interfaces.ServiceIdentifier = + Symbol('mo2LaunchDescriptorFactory'); diff --git a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterDescriptorFactory.ts b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterDescriptorFactory.ts index 09b171a..ff23e23 100644 --- a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterDescriptorFactory.ts +++ b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterDescriptorFactory.ts @@ -8,6 +8,7 @@ import { commands, Uri, env, + CancellationTokenSource, } from 'vscode'; import { PapyrusGame, @@ -26,6 +27,18 @@ import { IDebugSupportInstallService, DebugSupportInstallState } from './DebugSu import { ILanguageClientManager } from '../server/LanguageClientManager'; import { showGameDisabledMessage, showGameMissingMessage } from '../features/commands/InstallDebugSupportCommand'; import { inject, injectable } from 'inversify'; +import { DebugLaunchState, IDebugLauncherService, LaunchCommand } from './DebugLauncherService'; +import { IMO2LauncherDescriptor, IMO2LaunchDescriptorFactory } from './MO2LaunchDescriptorFactory'; +import { + GetErrorMessageFromStatus, + IMO2ConfiguratorService, + MO2LaunchConfigurationStatus, +} from './MO2ConfiguratorService'; +import path from 'path'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import { isMO2ButNotThisOneRunning, killAllMO2Processes } from './MO2Helpers'; +const exists = promisify(fs.exists); const noopExecutable = new DebugAdapterExecutable('node', ['-e', '""']); @@ -50,6 +63,9 @@ export class PapyrusDebugAdapterDescriptorFactory implements DebugAdapterDescrip private readonly _configProvider: IExtensionConfigProvider; private readonly _pathResolver: IPathResolver; private readonly _debugSupportInstaller: IDebugSupportInstallService; + private readonly _debugLauncher: IDebugLauncherService; + private readonly _MO2LaunchDescriptorFactory: IMO2LaunchDescriptorFactory; + private readonly _MO2ConfiguratorService: IMO2ConfiguratorService; private readonly _registration: Disposable; constructor( @@ -57,19 +73,83 @@ export class PapyrusDebugAdapterDescriptorFactory implements DebugAdapterDescrip @inject(ICreationKitInfoProvider) creationKitInfoProvider: ICreationKitInfoProvider, @inject(IExtensionConfigProvider) configProvider: IExtensionConfigProvider, @inject(IPathResolver) pathResolver: IPathResolver, - @inject(IDebugSupportInstallService) debugSupportInstaller: IDebugSupportInstallService + @inject(IDebugSupportInstallService) debugSupportInstaller: IDebugSupportInstallService, + @inject(IDebugLauncherService) debugLauncher: IDebugLauncherService, + @inject(IMO2LaunchDescriptorFactory) mo2LaunchDescriptorFactory: IMO2LaunchDescriptorFactory, + @inject(IMO2ConfiguratorService) mo2ConfiguratorService: IMO2ConfiguratorService ) { this._languageClientManager = languageClientManager; this._creationKitInfoProvider = creationKitInfoProvider; this._configProvider = configProvider; this._pathResolver = pathResolver; this._debugSupportInstaller = debugSupportInstaller; - + this._debugLauncher = debugLauncher; + this._MO2LaunchDescriptorFactory = mo2LaunchDescriptorFactory; + this._MO2ConfiguratorService = mo2ConfiguratorService; this._registration = debug.registerDebugAdapterDescriptorFactory('papyrus', this); } - private async ensureReadyFlow(game: PapyrusGame) { - const installState = await this._debugSupportInstaller.getInstallState(game); + private async _ShowAttachDebugSupportInstallMessage(game: PapyrusGame) { + const getExtenderOption = `Get ${getScriptExtenderName(game)}`; + const installOption = `Install ${getScriptExtenderName(game)} Plugin`; + + const selectedInstallOption = await window.showInformationMessage( + `Papyrus debugging support requires a plugin for ${getDisplayNameForGame( + game + )} Script Extender (${getScriptExtenderName( + game + )}) to be installed. After installation has completed, launch ${getShortDisplayNameForGame( + game + )} with ${getScriptExtenderName(game)} and wait until the main menu has loaded.`, + getExtenderOption, + installOption, + 'Cancel' + ); + + switch (selectedInstallOption) { + case installOption: + commands.executeCommand(`papyrus.${game}.installDebuggerSupport`); + break; + case getExtenderOption: + env.openExternal(Uri.parse(getScriptExtenderUrl(game))); + break; + } + return false; + } + + private async _ShowLaunchDebugSupportInstallMessage( + game: PapyrusGame, + launchType: 'MO2' | 'XSE', + launcher: IMO2LauncherDescriptor + ) { + const installOption = `Fix Configuration`; + const state = await this._MO2ConfiguratorService.getStateFromConfig(launcher); + if (state !== MO2LaunchConfigurationStatus.Ready) { + const errorMessage = GetErrorMessageFromStatus(state); + const selectedInstallOption = await window.showInformationMessage( + `The following configuration problems were encountered while attempting to launch ${getDisplayNameForGame( + game + )}:\n${errorMessage}\nWould you like to fix the configuration?`, + installOption, + 'Cancel' + ); + switch (selectedInstallOption) { + case installOption: + if (launchType === 'MO2') { + commands.executeCommand(`papyrus.${game}.installDebuggerSupport`, [launchType, launcher]); + } else { + commands.executeCommand(`papyrus.${game}.installDebuggerSupport`, [launchType]); + } + break; + case 'Cancel': + return true; + } + } + return false; + } + + private async _attachEnsureGameInstalled(game: PapyrusGame, modsDir?: string) { + const installState = await this._debugSupportInstaller.getInstallState(game, modsDir); switch (installState) { case DebugSupportInstallState.incorrectVersion: { @@ -95,37 +175,11 @@ export class PapyrusDebugAdapterDescriptorFactory implements DebugAdapterDescrip if (selectedUpdateOption === 'Cancel' || selectedUpdateOption === undefined) { return false; } - break; } - case DebugSupportInstallState.notInstalled: { - const getExtenderOption = `Get ${getScriptExtenderName(game)}`; - const installOption = `Install ${getScriptExtenderName(game)} Plugin`; - - const selectedInstallOption = await window.showInformationMessage( - `Papyrus debugging support requires a plugin for ${getDisplayNameForGame( - game - )} Script Extender (${getScriptExtenderName( - game - )}) to be installed. After installation has completed, launch ${getShortDisplayNameForGame( - game - )} with ${getScriptExtenderName(game)} and wait until the main menu has loaded.`, - getExtenderOption, - installOption, - 'Cancel' - ); - - switch (selectedInstallOption) { - case installOption: - commands.executeCommand(`papyrus.${game}.installDebuggerSupport`); - break; - case getExtenderOption: - env.openExternal(Uri.parse(getScriptExtenderUrl(game))); - break; - } - return false; - } + case DebugSupportInstallState.notInstalled: + return await this._ShowAttachDebugSupportInstallMessage(game); case DebugSupportInstallState.gameDisabled: showGameDisabledMessage(game); return false; @@ -133,7 +187,10 @@ export class PapyrusDebugAdapterDescriptorFactory implements DebugAdapterDescrip showGameMissingMessage(game); return false; } + return true; + } + async ensureGameRunning(game: PapyrusGame) { if (!(await getGameIsRunning(game))) { const selectedGameRunningOption = await window.showWarningMessage( `Make sure that ${getDisplayNameForGame(game)} is running and is either in-game or at the main menu.`, @@ -158,13 +215,98 @@ export class PapyrusDebugAdapterDescriptorFactory implements DebugAdapterDescrip if (game !== PapyrusGame.fallout4 && game !== PapyrusGame.skyrimSpecialEdition) { throw new Error(`'${game}' is not supported by the Papyrus debugger.`); } + let launched = DebugLaunchState.success; - if (!(await this.ensureReadyFlow(game))) { - session.configuration.noop = true; + if (session.configuration.request === 'launch') { + // check if the game is running + if (await getGameIsRunning(game)) { + throw new Error( + `'${getDisplayNameForGame( + game + )}' is already running. Please close it before launching the debugger.` + ); + } + // run the launcher with the args from the configuration + // if the launcher is MO2 + let launcherPath: string = session.configuration.launcherPath || ''; + if (!launcherPath) { + throw new Error(`'Invalid launch configuration. Launcher path is missing.`); + } + launcherPath = path.normalize(launcherPath); + if (!launcherPath || !(await exists(launcherPath))) { + throw new Error(`'Path does not exist!`); + } + const launcherArgs: string[] = session.configuration.args || []; + let LauncherCommand: LaunchCommand; + if (session.configuration.launchType === 'MO2') { + if (session.configuration.mo2Config === undefined) { + throw new Error(`'Invalid launch configuration. MO2 configuration is missing.`); + } + const launcher = await this._MO2LaunchDescriptorFactory.createMO2LaunchDecriptor( + launcherPath, + launcherArgs, + session.configuration.mo2Config, + game + ); + const state = await this._MO2ConfiguratorService.getStateFromConfig(launcher); + if (state !== MO2LaunchConfigurationStatus.Ready) { + if (!(await this._ShowLaunchDebugSupportInstallMessage(game, 'MO2', launcher))) { + session.configuration.noop = true; + return noopExecutable; + } + } - return noopExecutable; + // Configuration is ready, get the launch command + LauncherCommand = launcher.getLaunchCommand(); + + // If MO2 is running and the profile is not the one we want to launch, the launch will fuck up, kill it + if ( + (await isMO2ButNotThisOneRunning(launcher.MO2EXEPath)) || + launcher.instanceInfo.selectedProfile !== launcher.profileToLaunchData.name + ) { + await killAllMO2Processes(); + } + } else if (session.configuration.launchType === 'XSE') { + LauncherCommand = { command: launcherPath, args: launcherArgs }; + } else { + // throw an error indicated the launch configuration is invalid + throw new Error(`'Invalid launch configuration.`); + } + + const cancellationSource = new CancellationTokenSource(); + const cancellationToken = cancellationSource.token; + const port = session.configuration.port || getDefaultPortForGame(game); + const wait_message = window.setStatusBarMessage( + `Waiting for ${getDisplayNameForGame(game)} to start...`, + 30000 + ); + launched = await this._debugLauncher.runLauncher(LauncherCommand, game, port, cancellationToken); + wait_message.dispose(); + } else { + if (!(await this._attachEnsureGameInstalled(game))) { + session.configuration.noop = true; + return noopExecutable; + } } + if (launched != DebugLaunchState.success) { + if (launched === DebugLaunchState.cancelled) { + session.configuration.noop = true; + return noopExecutable; + } + if (launched === DebugLaunchState.multipleGamesRunning) { + const errMessage = `Multiple ${getDisplayNameForGame( + game + )} instances are running, shut them down and try again.`; + window.showErrorMessage(errMessage); + } + // throw an error indicating the launch failed + throw new Error(`'${game}' failed to launch.`); + // attach + } else if (!(await this.ensureGameRunning(game))) { + session.configuration.noop = true; + return noopExecutable; + } const config = (await this._configProvider.config.pipe(take(1)).toPromise())[game]; const creationKitInfo = await this._creationKitInfoProvider.infos.get(game)!.pipe(take(1)).toPromise(); diff --git a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterTracker.ts b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterTracker.ts index db094a6..4419c26 100644 --- a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterTracker.ts +++ b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugAdapterTracker.ts @@ -1,27 +1,33 @@ +import { inject, injectable } from 'inversify'; import { DebugAdapterTrackerFactory, DebugSession, DebugAdapterTracker, window, Disposable, debug } from 'vscode'; +import { IDebugLauncherService } from './DebugLauncherService'; +@injectable() export class PapyrusDebugAdapterTrackerFactory implements DebugAdapterTrackerFactory, Disposable { + private readonly _debugLauncher: IDebugLauncherService; private readonly _registration: Disposable; - constructor() { + constructor(@inject(IDebugLauncherService) debugLauncher: IDebugLauncherService) { + this._debugLauncher = debugLauncher; this._registration = debug.registerDebugAdapterTrackerFactory('papyrus', this); } async createDebugAdapterTracker(session: DebugSession): Promise { - return new PapyrusDebugAdapterTracker(session); + return new PapyrusDebugAdapterTracker(session, this._debugLauncher); } dispose() { this._registration.dispose(); } } - export class PapyrusDebugAdapterTracker implements DebugAdapterTracker { + private readonly _debugLauncher: IDebugLauncherService; private readonly _session: DebugSession; private _showErrorMessages = true; - constructor(session: DebugSession) { + constructor(session: DebugSession, debugLauncher: IDebugLauncherService) { + this._debugLauncher = debugLauncher; this._session = session; } @@ -38,6 +44,7 @@ export class PapyrusDebugAdapterTracker implements DebugAdapterTracker { } onExit(code: number | undefined, signal: string | undefined) { + this._debugLauncher.tearDownAfterDebug(); if (!this._showErrorMessages || this._session.configuration.noop) { return; } diff --git a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugConfigurationProvider.ts b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugConfigurationProvider.ts index 8c1d0d6..7caa9dc 100644 --- a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugConfigurationProvider.ts +++ b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugConfigurationProvider.ts @@ -1,7 +1,11 @@ -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import { DebugConfigurationProvider, CancellationToken, WorkspaceFolder, debug, Disposable } from 'vscode'; +import { IPathResolver } from '../common/PathResolver'; import { PapyrusGame } from '../PapyrusGame'; -import { IPapyrusDebugConfiguration } from './PapyrusDebugSession'; +import { GetPapyrusGameFromMO2GameID } from './MO2Helpers'; +import { FindInstanceForEXE, parseMoshortcutURI } from '../common/MO2Lib'; +import { MO2Config, IPapyrusDebugConfiguration } from './PapyrusDebugSession'; +import { getHomeFolder, getLocalAppDataFolder, getTempFolder, getUserName } from '../common/OSHelpers'; // TODO: Auto install F4SE plugin // TODO: Warn if port is not open/if Fallout4.exe is not running @@ -13,32 +17,175 @@ import { IPapyrusDebugConfiguration } from './PapyrusDebugSession'; @injectable() export class PapyrusDebugConfigurationProvider implements DebugConfigurationProvider, Disposable { private readonly _registration: Disposable; + private readonly _pathResolver: IPathResolver; - constructor() { + constructor(@inject(IPathResolver) pathResolver: IPathResolver) { + this._pathResolver = pathResolver; this._registration = debug.registerDebugConfigurationProvider('papyrus', this); } async provideDebugConfigurations( _folder: WorkspaceFolder | undefined, _token?: CancellationToken + // TODO: FIX THIS ): Promise { - return [ - { - type: 'papyrus', - name: 'Fallout 4', - request: 'attach', - game: PapyrusGame.fallout4, - }, - ]; + const PapyrusAttach = { + type: 'papyrus', + name: 'Fallout 4', + game: PapyrusGame.fallout4, + request: 'attach', + projectPath: '${workspaceFolder}/fallout4.ppj', + } as IPapyrusDebugConfiguration; + const PapyrusMO2Launch = { + type: 'papyrus', + name: 'Fallout 4 (Launch with MO2)', + game: PapyrusGame.fallout4, + request: 'launch', + launchType: 'MO2', + launcherPath: 'C:/Modding/MO2/ModOrganizer.exe', + mo2Config: { + shortcutURI: 'moshortcut://Fallout 4:F4SE', + } as MO2Config, + } as IPapyrusDebugConfiguration; + const PapyruseXSELaunch = { + type: 'papyrus', + name: 'Fallout 4 (Launch with F4SE)', + game: PapyrusGame.fallout4, + request: 'launch', + launchType: 'XSE', + launcherPath: 'C:/Program Files (x86)/Steam/steamapps/common/Fallout 4/f4se_loader.exe', + args: ['-skipIntro'], + } as IPapyrusDebugConfiguration; + return [PapyrusMO2Launch, PapyruseXSELaunch, PapyrusAttach]; } - // async resolveDebugConfiguration( - // folder: WorkspaceFolder | undefined, - // debugConfiguration: DebugConfiguration, - // token?: CancellationToken - // ): Promise { - // return null; - // } + async resolveDebugConfiguration( + _folder: WorkspaceFolder | undefined, + debugConfiguration: IPapyrusDebugConfiguration, + _token?: CancellationToken + ): Promise { + if (debugConfiguration.game !== undefined && debugConfiguration.request !== undefined) { + if (debugConfiguration.request === 'launch') { + if (debugConfiguration.launchType === 'MO2') { + if ( + debugConfiguration.mo2Config !== undefined && + debugConfiguration.mo2Config.shortcutURI !== undefined + ) { + return debugConfiguration; + } + } else if (debugConfiguration.launchType === 'XSE') { + if (debugConfiguration.XSELoaderPath !== undefined) { + return debugConfiguration; + } + } + } else if (debugConfiguration.request === 'attach') { + return debugConfiguration; + } + } + throw new Error('Invalid debug configuration.'); + return undefined; + } + + // TODO: We might not want to do this + // substitute all the environment variables in the given string + // environment variables are of the form ${env:VARIABLE_NAME} + async substituteEnvVars(string: string): Promise { + const envVars = string.match(/\$\{env:([^}]+)\}/g); + if (envVars !== null) { + for (const envVar of envVars) { + if (envVar === undefined || envVar === null) { + continue; + } + const matches = envVar?.match(/\$\{env:([^}]+)\}/); + if (matches === null || matches.length < 2) { + continue; + } + const envVarName = matches[1]; + let envVarValue: string | undefined; + + switch (envVarName) { + case 'LOCALAPPDATA': + envVarValue = getLocalAppDataFolder(); + break; + case 'USERNAME': + envVarValue = getUserName(); + break; + case 'HOMEPATH': + envVarValue = getHomeFolder(); + break; + case 'TEMP': + envVarValue = getTempFolder(); + break; + default: + envVarValue = undefined; + break; + } + + if (envVarValue === undefined) { + envVarValue = ''; + } + string = string.replace(envVar, envVarValue); + } + } + return string; + } + + // TODO: Check that all of these exist + async prepMo2Config(launcherPath: string, mo2Config: MO2Config, game: PapyrusGame): Promise { + let instanceINI = mo2Config.instanceIniPath; + if (!instanceINI) { + const { instanceName } = parseMoshortcutURI(mo2Config.shortcutURI); + const instanceInfo = await FindInstanceForEXE(launcherPath, instanceName); + if ( + instanceInfo && + GetPapyrusGameFromMO2GameID(instanceInfo.gameName) && + GetPapyrusGameFromMO2GameID(instanceInfo.gameName) === game + ) { + instanceINI = instanceInfo.iniPath; + } + } else { + instanceINI = mo2Config.instanceIniPath + ? await this.substituteEnvVars(mo2Config.instanceIniPath) + : mo2Config.instanceIniPath; + } + return { + shortcutURI: mo2Config.shortcutURI, + profile: mo2Config.profile, + instanceIniPath: instanceINI, + } as MO2Config; + } + + async resolveDebugConfigurationWithSubstitutedVariables( + _folder: WorkspaceFolder | undefined, + debugConfiguration: IPapyrusDebugConfiguration, + _token?: CancellationToken + ): Promise { + if (debugConfiguration.request === 'launch' && debugConfiguration.launcherPath) { + const path = await this.substituteEnvVars(debugConfiguration.launcherPath); + if (path === undefined) { + throw new Error('Invalid debug configuration.'); + } + if (debugConfiguration.launchType === 'MO2') { + if (debugConfiguration.mo2Config === undefined) { + throw new Error('Invalid debug configuration.'); + } + debugConfiguration.mo2Config = await this.prepMo2Config( + path, + debugConfiguration.mo2Config, + debugConfiguration.game + ); + return debugConfiguration; + } else if (debugConfiguration.launchType === 'XSE') { + return debugConfiguration; + } + } + // else... + else if (debugConfiguration.request === 'attach') { + return debugConfiguration; + } + throw new Error('Invalid debug configuration.'); + return undefined; + } dispose() { this._registration.dispose(); diff --git a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugSession.ts b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugSession.ts index 864c2e9..9a018e3 100644 --- a/src/papyrus-lang-vscode/src/debugger/PapyrusDebugSession.ts +++ b/src/papyrus-lang-vscode/src/debugger/PapyrusDebugSession.ts @@ -6,8 +6,101 @@ export interface IPapyrusDebugSession extends DebugSession { readonly configuration: IPapyrusDebugConfiguration; } +export interface MO2Config { + /** + * The shortcut URI for the Mod Organizer 2 profile to launch + * + * You can get this from the Mod Organizer 2 shortcut menu. + * + * It is in the format: `moshortcut://:`. + * + * If the MO2 installation is portable, the instance name is blank. + * + * + * + * Examples: + * - non-portable: `moshortcut://Skyrim Special Edition:SKSE` + * - portable: `moshortcut://:F4SE` + */ + shortcutURI: string; + /** + * The name of the Mod Organizer 2 profile to launch with. + * + * Defaults to the currently selected profile + */ + profile?: string; + /** + * The path to the Mod Organizer 2 instance ini for this game. + * This is only necessary to be set if the debugger has difficulty finding the MO2 instance location + * + * - If the Mod Organizer 2 exe is a portable installation, this is located in the parent folder. + * - If it is a non-portable installation, this in `%LOCALAPPDATA%/ModOrganizer//ModOrganizer.ini` + * + * Examples: + * - `C:/Users//AppData/Local/ModOrganizer/Fallout4/ModOrganizer.ini` + * - `C:/Modding/MO2/ModOrganizer.ini` + */ + instanceIniPath?: string; +} + +export interface XSEConfig {} + export interface IPapyrusDebugConfiguration extends DebugConfiguration { - readonly game: PapyrusGame; - readonly projectPath?: string; - readonly port?: number; + /** + * The game to debug ('fallout4', 'skyrim', 'skyrimSpecialEdition') + */ + game: PapyrusGame; + /** + * The path to the project to debug + */ + projectPath?: string; + port?: number; + /** + * The type of debug request + * - 'attach': Attaches to a running game + * - 'launch': Launches the game + */ + request: 'attach' | 'launch'; + + //TODO: split these into separate interfaces + /** + * The type of launcher to use + * + * - 'XSE': Launches the game using SKSE/F4SE without a mod manager + * - 'MO2': Launches the game using Mod Organizer 2 + * */ + launchType?: 'XSE' | 'MO2'; + + /** + * The path to the launcher executable + * + * - If the launch type is 'MO2', this is the path to the Mod Organizer 2 executable. + * - If the launch type is 'XSE', this is the path to the f4se/skse loader executable. + * + * Examples: + * - "C:/Program Files/Mod Organizer 2/ModOrganizer.exe" + * - "C:/Program Files (x86)/Steam/steamapps/common/Skyrim Special Edition/skse64_loader.exe" + * - "C:/Program Files (x86)/Steam/steamapps/common/Fallout 4/f4se_loader.exe" + * + */ + launcherPath?: string; + + /** + * + * Configuration for Mod Organizer 2 + * + * Only used if launchType is 'MO2' + * + */ + mo2Config?: MO2Config; + + /** + * (optional, advanced) Additional arguments to pass to the launcher + */ + args?: string[]; + + /** + * Ignore debugger configuration checks and launch + */ + ignoreConfigChecks?: boolean; } diff --git a/src/papyrus-lang-vscode/src/debugger/PexParser.ts b/src/papyrus-lang-vscode/src/debugger/PexParser.ts new file mode 100644 index 0000000..afb5b27 --- /dev/null +++ b/src/papyrus-lang-vscode/src/debugger/PexParser.ts @@ -0,0 +1,429 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Parser } from 'binary-parser'; +import * as fs from 'fs'; +import { PapyrusGame } from '../PapyrusGame'; + +// This interface contains the same members that are in the "Header" class in F:\workspace\skyrim-mod-workspace\Champollion\Pex\Header.hpp +// The members are in the same order as they are in the C++ header file. +export interface PexHeader { + /** + * Determines the game that the pex file was compiled for. + * This is determined by the endianness of the magic number. + * If the magic number is 0xFA57C0DE, then the game is Fallout 4. + * If the magic number is 0xDEC057FA, then the game is Skyrim. + */ + Game: PapyrusGame; + /** + * The major version game that the pex file was compiled for. + */ + MajorVersion: number; + /** + * The minor version game that the pex file was compiled for. + */ + MinorVersion: number; + /** + * The game ID of the game that the pex file was compiled for. + */ + GameID: number; + /** + * The timestamp of when the pex file was compiled. + */ + CompileTime: number; + /** + * The name of the source file that was compiled to create the pex file. + * This can either be an absolute path (on the *ORIGINAL MACHINE* that it was compiled on) + * or a relative path. + */ + SourceFileName: string; + /** + * The name of the user that compiled the pex file. + * This is the name of the user on the *ORIGINAL MACHINE* that it was compiled on. + */ + UserName: string; + /** + * The name of the computer that compiled the pex file. + */ + ComputerName: string; +} + +export interface PexBinary { + Path: string; + Header: PexHeader; + DebugInfo: DebugInfo; + // TODO: add the rest of the data +} + +export enum FunctionType { + Method, + Getter, + Setter, +} +// names these are all indexed into the string table, but copied into the interface +export interface FunctionInfo { + ObjectName: string; + StateName: string; + FunctionName: string; + FunctionType: FunctionType; + LineNumbers: number[]; +} + +export interface PropertyGroup { + ObjectName: string; + GroupName: string; + DocString: string; + UserFlags: number; + Names: string[]; +} + +export interface StructOrder { + StructName: string; + OrderName: string; + Names: string[]; +} + +export interface DebugInfo { + ModificationTime: number; + FunctionInfos: FunctionInfo[]; + PropertyGroups: PropertyGroup[]; + /** + * Fallout 4 only + */ + StructOrders?: StructOrder[]; +} + +// TODO: maybe implement this +export class PexIndexedString { + public readonly index: number; + public readonly str: string; + constructor(index: number, str: string) { + this.index = index; + this.str = str; + } + + public toString(): string { + return this.str; + } +} + +export class PexStringTable { + public readonly strings: string[]; + constructor(strings: string[]) { + this.strings = strings; + } +} + +const LE_MAGIC_NUMBER = 0xfa57c0de; // 4200055006, values when read little endian +const BE_MAGIC_NUMBER = 0xdec057fa; // 3737147386, values when read little endian + +function DetermineEndianness(buffer: Buffer) { + const magicNumber = buffer.readUInt32LE(0); + return DetermineEndiannessFromNumber(magicNumber); +} + +function DetermineEndiannessFromNumber(number: number) { + if (number === LE_MAGIC_NUMBER) { + return 'little'; + } else if (number === BE_MAGIC_NUMBER) { + return 'big'; + } else { + return undefined; + } +} + +function getGameFromEndianness(endianness: 'little' | 'big') { + if (endianness === 'little') { + return PapyrusGame.fallout4; + } + return PapyrusGame.skyrimSpecialEdition; +} + +/** + * Parses a pex file. + * + * NOTE: This only currently implements the parsing of the header and the debug info. + */ +export class PexReader { + public readonly path; + + public readonly game: PapyrusGame = PapyrusGame.skyrimSpecialEdition; + constructor(path: string) { + this.path = path; + } + private endianness: 'little' | 'big' = 'little'; + private stringTable: PexStringTable = new PexStringTable([]); + + // constants + + // parsers + private readonly StringParser = () => + new Parser() + .uint16('__strlen') + .string('__string', { length: '__strlen', encoding: 'ascii', zeroTerminated: false }); + + private readonly _strNest = { type: this.StringParser(), formatter: (x: any): string => x.__string }; + + private readonly StringTableParser = new Parser().uint16('__tbllen').array('__strings', { + type: this.StringParser(), + formatter: (x: any): string[] => x.map((y: any) => y.__string), + length: '__tbllen', + }); + + private readonly _strTableNest = { + type: this.StringTableParser, + formatter: (x: any): PexStringTable => { + // TODO: Global state hack to get around not being able to reference the parsed string table in the middle of the parse + this.stringTable = new PexStringTable(x.__strings); + return this.stringTable; + }, + }; + + private readonly FunctionInfoRawParser = () => + new Parser() + .uint16('ObjectName') + .uint16('StateName') + .uint16('FunctionName') + .uint8('FunctionType') + .uint16('LineNumbersCount') + .array('LineNumbers', { + type: this.GetUintType(), + length: 'LineNumbersCount', + formatter: (x: any): number[] => x.map((y: any) => y.__val), + }); + + private readonly FunctionInfosParser = () => + new Parser().uint16('__FIlen').array('__infos', { + type: this.FunctionInfoRawParser(), + length: '__FIlen', + formatter: (x: any): FunctionInfo[] => + x.map((y: any) => { + const functinfo = { + ObjectName: this.TableLookup(y.ObjectName), + StateName: this.TableLookup(y.StateName), + FunctionName: this.TableLookup(y.FunctionName), + FunctionType: y.FunctionType as FunctionType, + LineNumbers: y.LineNumbers, + } as FunctionInfo; + return functinfo; + }), + }); + public GetEndianness() { + return this.endianness; + } + private GetUintType() { + return new Parser().uint16('__val'); + } + private readonly PropertyGroupRawParser = () => + new Parser() + .uint16('ObjectName') + .uint16('GroupName') + .uint16('DocString') + .uint32('UserFlags') + .uint16('NamesCount') + .array('Names', { + type: this.GetUintType(), + length: 'NamesCount', + formatter: (x: any): number[] => x.map((y: any) => y.__val), + }); + private TableLookup(x: number) { + if (x >= this.stringTable.strings.length) { + return ''; + } + return this.stringTable.strings[x]; + } + private readonly PropertyGroupsParser = () => + new Parser().uint16('__PGlen').array('__infos', { + type: this.PropertyGroupRawParser(), + length: '__PGlen', + formatter: (x: any): PropertyGroup[] => + x.map((y: any) => { + const pgroups = { + ObjectName: this.TableLookup(y.ObjectName), + GroupName: this.TableLookup(y.GroupName), + DocString: this.TableLookup(y.DocString), + UserFlags: y.UserFlags, + Names: y.Names.map((z: any) => this.TableLookup(z)), + } as PropertyGroup; + return pgroups; + }), + }); + + private readonly StructOrderRawParser = () => + new Parser() + .uint16('StructName') + .uint16('OrderName') + .uint16('NamesCount') + .array('Names', { + type: this.GetUintType(), + length: 'NamesCount', + formatter: (x: any): number[] => x.map((y: any) => y.__val), + }); + + private readonly StructOrdersParser = () => + new Parser().uint16('__SOlen').array('__infos', { + type: this.StructOrderRawParser(), + length: '__SOlen', + formatter: (x: any): StructOrder[] => + x.map((y: any) => { + const sorders = { + StructName: this.TableLookup(y.StructName), + OrderName: this.TableLookup(y.OrderName), + Names: y.Names.map((z: any) => this.TableLookup(z)), + } as StructOrder; + return sorders; + }), + }); + + private readonly _doParseDebugInfo = () => { + return new Parser() + .uint64('ModificationTime') + .nest('FunctionInfos', { + type: this.FunctionInfosParser(), + formatter: (x: any): FunctionInfo[] => x.__infos, + }) + .nest('PropertyGroups', { + type: this.PropertyGroupsParser(), + formatter: (x: any): PropertyGroup[] => x.__infos, + }) + .choice('StructOrders', { + tag: () => { + const val = this.endianness === 'little' ? 1 : 0; + return val; + }, + choices: { + 0: new Parser().skip(0), + 1: this.StructOrdersParser(), + }, + formatter: (x: any): StructOrder[] | undefined => { + if (this.endianness === 'little' && x) { + return x.__infos; + } + return undefined; + }, + }); + }; + + private readonly ParseDebugInfo = () => + new Parser().uint8('HasDebugInfo').choice('DebugInfo', { + tag: 'HasDebugInfo', + choices: { + 0: new Parser().skip(0), + 1: this._doParseDebugInfo(), + }, + formatter: (x: any): DebugInfo | undefined => { + if (!x) { + return undefined; + } + return x; + }, + }); + + private readonly _debugInfoNest = { + type: this.ParseDebugInfo(), + formatter: (x: any): DebugInfo | undefined => (x ? x.DebugInfo : undefined), + }; + + private readonly HeaderParser = () => + new Parser() + .uint32('MagicNumber') + .uint8('MajorVersion') + .uint8('MinorVersion') + .uint16('GameID') + .uint64('CompileTime') + .nest('SourceFileName', this._strNest) + .nest('UserName', this._strNest) + .nest('ComputerName', this._strNest); + + private readonly _HeaderNest = (endianness: 'little' | 'big') => { + return { + type: this.HeaderParser(), + formatter: (x: any): PexHeader => { + return { + Game: getGameFromEndianness(endianness), + MajorVersion: x.MajorVersion, + MinorVersion: x.MinorVersion, + GameID: x.GameID, + CompileTime: x.CompileTime, + SourceFileName: x.SourceFileName, + UserName: x.UserName, + ComputerName: x.ComputerName, + }; + }, + }; + }; + + private ReadPexBinary(buffer: Buffer): PexBinary | undefined { + const endianness: 'little' | 'big' | undefined = DetermineEndianness(buffer); + if (!endianness) { + return undefined; + } + const Pex = new Parser() + .endianess(endianness) + .nest('Header', this._HeaderNest(endianness)) + .nest('StringTable', this._strTableNest) + .nest('DebugInfo', this._debugInfoNest) + .parse(buffer); + + return { + Path: this.path, + Header: Pex.Header, + DebugInfo: Pex.DebugInfo, + }; + } + + private ReadHeader(buffer: Buffer) { + return new Parser().endianess(this.endianness).nest('Header', this._HeaderNest(this.endianness)).parse(buffer); + } + + public async ReadPexHeader(): Promise { + // read the binary file from the path into a byte buffer + if (!fs.existsSync(this.path) || !fs.lstatSync(this.path).isFile()) { + return undefined; + } + + const buffer = fs.readFileSync(this.path); + if (!buffer || buffer.length < 4) { + return undefined; + } + const endianness: 'little' | 'big' | undefined = DetermineEndianness(buffer); + if (!endianness) { + return undefined; + } + this.endianness = endianness; + + return this.ReadHeader(buffer); + } + // not complete + async ReadPex(): Promise { + const buffer = fs.readFileSync(this.path); + if (!buffer || buffer.length < 4) { + return undefined; + } + const endianness: 'little' | 'big' | undefined = DetermineEndianness(buffer); + if (!endianness) { + return undefined; + } + this.endianness = endianness; + return this.ReadPexBinary(buffer); + } +} + +// returns the 64-bit timestamp of when the pex file was compiled +// if file not found or not parsable, returns -1 +export async function GetCompiledTime(path: string): Promise { + const pex = new PexReader(path); + const header = await pex.ReadPexHeader(); + if (!header) { + return -1; + } + return header.CompileTime; +} + +// // Test the PexReader +// let pexreader = new PexReader( +// 'F:\\workspace\\skyrim-mod-workspace\\papyrus-lang\\src\\papyrus-lang-vscode\\_wetbpautoadjust.pex' +// ); + +// pexreader.ReadPex().then((pex) => { +// console.log(pex); +// console.log('done'); +// }); diff --git a/src/papyrus-lang-vscode/src/features/PyroTaskProvider.ts b/src/papyrus-lang-vscode/src/features/PyroTaskProvider.ts index b3219ab..c18a8cf 100644 --- a/src/papyrus-lang-vscode/src/features/PyroTaskProvider.ts +++ b/src/papyrus-lang-vscode/src/features/PyroTaskProvider.ts @@ -14,7 +14,8 @@ import { import { CancellationToken, Disposable } from 'vscode-jsonrpc'; import { IPyroTaskDefinition, TaskOf, PyroGameToPapyrusGame } from './PyroTaskDefinition'; -import { PapyrusGame, getWorkspaceGameFromProjects } from '../PapyrusGame'; +import { getWorkspaceGameFromProjects } from '../WorkspaceGame'; +import { PapyrusGame } from '../PapyrusGame'; import { IPathResolver, PathResolver, pathToOsPath } from '../common/PathResolver'; import { inject, injectable } from 'inversify'; diff --git a/src/papyrus-lang-vscode/src/features/commands/InstallDebugSupportCommand.ts b/src/papyrus-lang-vscode/src/features/commands/InstallDebugSupportCommand.ts index 1d5ceea..5d9d921 100644 --- a/src/papyrus-lang-vscode/src/features/commands/InstallDebugSupportCommand.ts +++ b/src/papyrus-lang-vscode/src/features/commands/InstallDebugSupportCommand.ts @@ -1,9 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { IDebugSupportInstallService, DebugSupportInstallState } from '../../debugger/DebugSupportInstallService'; import { window, ProgressLocation } from 'vscode'; import { PapyrusGame, getDisplayNameForGame } from '../../PapyrusGame'; import { GameCommandBase } from './GameCommandBase'; -import { getGameIsRunning, waitWhile } from '../../Utilities'; +import { getGameIsRunning } from '../../Utilities'; +import { waitWhile } from '../../VsCodeUtilities'; import { inject, injectable } from 'inversify'; +import { IMO2ConfiguratorService } from '../../debugger/MO2ConfiguratorService'; +import { IMO2LauncherDescriptor } from '../../debugger/MO2LaunchDescriptorFactory'; export function showGameDisabledMessage(game: PapyrusGame) { window.showErrorMessage( @@ -22,14 +26,39 @@ export function showGameMissingMessage(game: PapyrusGame) { @injectable() export class InstallDebugSupportCommand extends GameCommandBase { private readonly _installer: IDebugSupportInstallService; - - constructor(@inject(IDebugSupportInstallService) installer: IDebugSupportInstallService) { + private readonly _mo2ConfiguratorService: IMO2ConfiguratorService; + constructor( + @inject(IDebugSupportInstallService) installer: IDebugSupportInstallService, + @inject(IMO2ConfiguratorService) mo2ConfiguratorService: IMO2ConfiguratorService + ) { super('installDebuggerSupport', [PapyrusGame.fallout4, PapyrusGame.skyrimSpecialEdition]); this._installer = installer; + this._mo2ConfiguratorService = mo2ConfiguratorService; + } + + // TODO: Fix the args + protected getLauncherDescriptor(...args: [any | undefined]): IMO2LauncherDescriptor | undefined { + // If we have args, it's a debugger launch. + if (args.length > 0) { + // args 0 indicates the launch type + const launchArgs: any[] = args[0]; + if (launchArgs.length < 1) { + return; + } + const launchType = launchArgs[0] as string; + if (launchType === 'XSE') { + // do stuff + } + if (launchArgs.length > 1 && launchType === 'MO2') { + return launchArgs[1] as IMO2LauncherDescriptor; + } + } + return undefined; } - protected async onExecute(game: PapyrusGame) { + protected async onExecute(game: PapyrusGame, ...args: [any | undefined]) { + const launcherDescriptor = this.getLauncherDescriptor(...args); const installed = await window.withProgress( { cancellable: true, @@ -58,7 +87,9 @@ export class InstallDebugSupportCommand extends GameCommandBase { return false; } - return await this._installer.installPlugin(game, token); + return launcherDescriptor + ? await this._mo2ConfiguratorService.fixDebuggerConfiguration(launcherDescriptor, token) + : await this._installer.installPlugin(game, token); } catch (error) { window.showErrorMessage( `Failed to install Papyrus debugger support for ${getDisplayNameForGame(game)}: ${error}` @@ -69,7 +100,9 @@ export class InstallDebugSupportCommand extends GameCommandBase { } ); - const currentStatus = await this._installer.getInstallState(game); + const currentStatus = launcherDescriptor + ? await this._mo2ConfiguratorService.getStateFromConfig(launcherDescriptor) + : await this._installer.getInstallState(game); if (installed) { if (currentStatus === DebugSupportInstallState.installedAsMod) { diff --git a/src/papyrus-lang-vscode/src/server/LanguageClientHost.ts b/src/papyrus-lang-vscode/src/server/LanguageClientHost.ts index 76ca829..e12beb4 100644 --- a/src/papyrus-lang-vscode/src/server/LanguageClientHost.ts +++ b/src/papyrus-lang-vscode/src/server/LanguageClientHost.ts @@ -1,13 +1,13 @@ import { Disposable, OutputChannel, window, TextDocument } from 'vscode'; import { LanguageClient, ILanguageClient, IToolArguments } from './LanguageClient'; -import { PapyrusGame, getShortDisplayNameForGame } from '../PapyrusGame'; +import { PapyrusGame, getShortDisplayNameForGame, getDefaultFlagsFileNameForGame } from '../PapyrusGame'; import { IGameConfig } from '../ExtensionConfigProvider'; import { Observable, BehaviorSubject, of } from 'rxjs'; import { ICreationKitInfo } from '../CreationKitInfoProvider'; import { DocumentScriptInfo } from './messages/DocumentScriptInfo'; import { shareReplay, take, switchMap } from 'rxjs/operators'; -import { getDefaultFlagsFileNameForGame, IPathResolver } from '../common/PathResolver'; +import { IPathResolver } from '../common/PathResolver'; import { ProjectInfos } from './messages/ProjectInfos'; import { inject } from 'inversify'; @@ -123,12 +123,12 @@ export class LanguageClientHost implements ILanguageClientHost, Disposable { this._status.next(ClientHostStatus.compilerMissing); return; } - + const defaultFlags = getDefaultFlagsFileNameForGame(this._game); const toolArguments: IToolArguments = { compilerAssemblyPath: this._creationKitInfo.resolvedCompilerPath, creationKitInstallPath: this._creationKitInfo.resolvedInstallPath, relativeIniPaths: this._config.creationKitIniFiles, - flagsFileName: getDefaultFlagsFileNameForGame(this._game), + flagsFileName: defaultFlags, ambientProjectName: 'Creation Kit', defaultScriptSourceFolder: this._creationKitInfo.config.Papyrus?.sScriptSourceFolder, defaultAdditionalImports: this._creationKitInfo.config.Papyrus?.sAdditionalImports, diff --git a/src/papyrus-lang-vscode/tsconfig.json b/src/papyrus-lang-vscode/tsconfig.json index d21b6d5..eb916f8 100644 --- a/src/papyrus-lang-vscode/tsconfig.json +++ b/src/papyrus-lang-vscode/tsconfig.json @@ -5,11 +5,15 @@ "allowSyntheticDefaultImports": true, "target": "es6", "outDir": "out", - "lib": ["es6"], + "lib": [ + "es6" + ], "sourceMap": true, "rootDir": "src", "experimentalDecorators": true, "strict": true }, - "include": ["src/**/*.ts"] -} + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file