From de69ed1e10985e7416d07775837fd0537654bacb Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Tue, 13 Oct 2026 20:51:39 +0000 Subject: [PATCH 1/4] Batch installer toolchain launches and stop initialization failure retries --- .github/CI.md | 6 + .github/workflows/quality.yml | 5 + .github/workflows/windows-installer.yml | 5 + Engine/README.md | 20 +++ Engine/docs/GAMEPLAY_PROGRAMMING.md | 12 ++ Engine/installer/RusticGameEngine.iss | 148 ++++++++----------- Engine/tools/manage-language-toolchains.ps1 | 125 +++++++++------- Engine/tools/test-language-toolchains.ps1 | 151 ++++++++++++++++++++ 8 files changed, 334 insertions(+), 138 deletions(-) create mode 100644 Engine/tools/test-language-toolchains.ps1 diff --git a/.github/CI.md b/.github/CI.md index 23ed0bfe..ce3ea19d 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -10,6 +10,12 @@ dependency/license audit, secret scan, Windows installer build, workflow syntax check, or installer release automation test fails or is skipped. The job summary links to the run; failed step logs contain diagnostic details. The fresh Windows installer is saved as a workflow artifact for seven days. +The installer job also runs Windows PowerShell 5.1 toolchain-manager regression tests +and silent packaged Setup tests. A local mock WinGet covers installed and missing +tools, ordinary package failures, and initialization errors `142`/`0xc0000142` without +downloading toolchains. Packaged tests verify a single PowerShell transcript for all +selected languages, that a failing WinGet is not retried for later languages, and +that bundled-only Setup does not start the toolchain manager. ## Installer releases diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 28f68f9b..bf5d04a5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -181,6 +181,11 @@ jobs: if (-not (Get-ChildItem 'dist/RusticGameEngine-Setup-*.exe' -ErrorAction SilentlyContinue)) { throw 'No fresh Windows installer was produced.' } + - name: Test toolchain failures and packaged Setup + run: | + $installer = Get-ChildItem 'dist/RusticGameEngine-Setup-*.exe' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + powershell -NoProfile -ExecutionPolicy Bypass -File ./tools/test-language-toolchains.ps1 -InstallerPath $installer.FullName + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: windows-installer diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 9f7be7c5..7dc6b81e 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -32,6 +32,11 @@ jobs: !/Engine/dist/ - name: Build Windows installer run: ./tools/build-windows-installer.ps1 + - name: Test toolchain failures and packaged Setup + run: | + $installer = Get-ChildItem 'dist/RusticGameEngine-Setup-*.exe' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + powershell -NoProfile -ExecutionPolicy Bypass -File ./tools/test-language-toolchains.ps1 -InstallerPath $installer.FullName + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Upload installer uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: diff --git a/Engine/README.md b/Engine/README.md index 7e54af9a..ecacd73d 100644 --- a/Engine/README.md +++ b/Engine/README.md @@ -70,6 +70,26 @@ removed through Windows Installed Apps. Because it is a desktop application, its folder is not added to `PATH` by default; pass `-InstallDirectoryOnPath` to select that installer option by default. +Setup checks and installs all selected optional toolchains in one hidden, +noninteractive PowerShell process as the original user. Already-installed toolchains +are skipped. If PowerShell cannot start, or WinGet returns an initialization failure +(`142` or `0xc0000142`), Setup reports the optional-toolchain failure once and leaves +the installed engine usable with its bundled languages. Setup always saves a log in +`%TEMP%` named `Setup Log ...txt`; the failure dialog gives its exact path. To choose +a persistent location, run `RusticGameEngine-Setup-.exe /LOG="C:\path\setup.log"`. +If PowerShell started, its transcript is in the original user's +`%LOCALAPPDATA%\Rustic Game Engine\logs\toolchain-install.log`. Repair the failing +PowerShell or Microsoft App Installer installation before retrying **Language +Toolchain Manager**. The Setup log records launch errors and exit codes even when +the transcript could not be created. + +On Windows, run the toolchain regression tests with +`powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\test-language-toolchains.ps1`. +Pass `-InstallerPath .\dist\RusticGameEngine-Setup-.exe` to also test silent +packaged Setup with all optional languages, simulated WinGet initialization failures, +and bundled languages only. The tests use a local mock WinGet executable and never +download or install external toolchains. + See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the decision-record and project-file safety conventions. diff --git a/Engine/docs/GAMEPLAY_PROGRAMMING.md b/Engine/docs/GAMEPLAY_PROGRAMMING.md index decca545..0091577f 100644 --- a/Engine/docs/GAMEPLAY_PROGRAMMING.md +++ b/Engine/docs/GAMEPLAY_PROGRAMMING.md @@ -236,6 +236,18 @@ installs every selected item through Windows Package Manager. Lua, JavaScript, a HTML/CSS are bundled. Additional toolchains can be installed later using **Rustic Game Engine > Language Toolchain Manager** in the Start menu or by rerunning Setup. +Setup checks all selected toolchains in one hidden PowerShell process, skipping +already-installed tools. If PowerShell cannot start or WinGet reports initialization +error `142` or `0xc0000142`, it stops the batch and reports the optional-toolchain +failure once. Rustic remains installed with its bundled languages. These are distinct +error codes; retain the full message and the failing executable when diagnosing them. +The failure dialog shows the Setup log path (`Setup Log ...txt` in `%TEMP%` by default). +Use `/LOG="C:\path\setup.log"` to choose a persistent log location. If PowerShell started, +check the original user's `%LOCALAPPDATA%\Rustic Game Engine\logs\toolchain-install.log` +for package details and WinGet output. Otherwise, the Setup log records the PowerShell +startup failure. Repair the failing PowerShell or Microsoft App Installer installation +before retrying **Language Toolchain Manager**. Setup does not require Windows Terminal. + ## HTML/CSS content HTML/CSS/PHP UI content is confined to the project's `ui/` directory. `.html` and diff --git a/Engine/installer/RusticGameEngine.iss b/Engine/installer/RusticGameEngine.iss index 57d02d9d..ecbbbb90 100644 --- a/Engine/installer/RusticGameEngine.iss +++ b/Engine/installer/RusticGameEngine.iss @@ -35,6 +35,7 @@ WizardStyle=modern MinVersion=10.0 CloseApplications=yes RestartApplications=no +SetupLogging=yes [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -93,111 +94,78 @@ Filename: "{app}\rustic-agent-backend.exe"; Parameters: "--install-user-integrat Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; Flags: nowait postinstall skipifsilent [Code] -function IsLanguageToolchainInstalled(Language: String): Boolean; -var - ResultCode: Integer; -begin - Result := ExecAsOriginalUser( - ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), - '-NoProfile -ExecutionPolicy Bypass -File "' + - ExpandConstant('{app}\tools\manage-language-toolchains.ps1') + - '" -Languages "' + Language + '" -CheckOnly', - ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode - ) and (ResultCode = 0); -end; - -function SelectedLanguageCount(): Integer; -begin - Result := 0; - if WizardIsComponentSelected('languages\python') and not IsLanguageToolchainInstalled('Python') then Result := Result + 1; - if WizardIsComponentSelected('languages\csharp') and not IsLanguageToolchainInstalled('CSharp') then Result := Result + 1; - if WizardIsComponentSelected('languages\ccpp') and not IsLanguageToolchainInstalled('CCpp') then Result := Result + 1; - if WizardIsComponentSelected('languages\java') and not IsLanguageToolchainInstalled('Java') then Result := Result + 1; - if WizardIsComponentSelected('languages\php') and not IsLanguageToolchainInstalled('Php') then Result := Result + 1; -end; - -function AnyOptionalLanguageSelected(): Boolean; +procedure AddSelectedLanguage(Component, Language: String; var Languages: String); begin - Result := - WizardIsComponentSelected('languages\python') or - WizardIsComponentSelected('languages\csharp') or - WizardIsComponentSelected('languages\ccpp') or - WizardIsComponentSelected('languages\java') or - WizardIsComponentSelected('languages\php'); + if WizardIsComponentSelected(Component) then + begin + if Languages <> '' then Languages := Languages + ','; + Languages := Languages + Language; + end; end; -procedure InstallLanguageToolchain(Language, DisplayName: String; - var CompletedCount: Integer; TotalCount: Integer; var Failures: String); +procedure InstallSelectedLanguageToolchains(); var + Languages: String; + Failure: String; + Started: Boolean; ResultCode: Integer; begin - WizardForm.StatusLabel.Caption := Format( - 'Installing %s (%d of %d)...', [DisplayName, CompletedCount + 1, TotalCount]); - WizardForm.ProgressGauge.Position := CompletedCount; + Languages := ''; + AddSelectedLanguage('languages\python', 'Python', Languages); + AddSelectedLanguage('languages\csharp', 'CSharp', Languages); + AddSelectedLanguage('languages\ccpp', 'CCpp', Languages); + AddSelectedLanguage('languages\java', 'Java', Languages); + AddSelectedLanguage('languages\php', 'Php', Languages); + if Languages = '' then Exit; + + WizardForm.StatusLabel.Caption := 'Checking and installing selected gameplay language toolchains...'; WizardForm.Refresh; - if not ExecAsOriginalUser( - ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), - '-NoProfile -ExecutionPolicy Bypass -File "' + - ExpandConstant('{app}\tools\manage-language-toolchains.ps1') + - '" -Languages "' + Language + '" -LogPath "' + - ExpandConstant('{localappdata}\Rustic Game Engine\logs\toolchain-install.log') + '"', - ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode - ) or (ResultCode <> 0) then - begin - if Failures <> '' then Failures := Failures + ', '; - Failures := Failures + DisplayName; + // The manager checks installed packages itself. Launch it once, rather than + // starting a new PowerShell host for each check and each installation. + Log('Selected optional language toolchains: ' + Languages); + Failure := ''; + ResultCode := -1; + try + Started := ExecAsOriginalUser( + ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + + ExpandConstant('{app}\tools\manage-language-toolchains.ps1') + + '" -Languages "' + Languages + '" -Setup', + ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode + ); + if not Started then + Failure := Format('Could not start PowerShell: Windows error %d (%s).', + [ResultCode, SysErrorMessage(ResultCode)]) + else if ResultCode <> 0 then + Failure := Format('Language Toolchain Manager exited with code %d (0x%.8x).', + [ResultCode, ResultCode]); + except + Failure := 'Could not start Language Toolchain Manager: ' + GetExceptionMessage; end; - CompletedCount := CompletedCount + 1; - WizardForm.ProgressGauge.Position := CompletedCount; + if Failure <> '' then + begin + Log(Failure); + WizardForm.StatusLabel.Caption := 'Rustic is installed; optional toolchain installation needs attention.'; + // Keep the engine usable and report one failure, including host failures + // that occur before PowerShell can create its own transcript. + SuppressibleMsgBox( + 'Rustic Game Engine was installed, but optional language toolchain installation did not finish.' + #13#10 + #13#10 + + Failure + #13#10 + #13#10 + + 'You can retry from Language Toolchain Manager in the Start menu.' + #13#10 + + 'Setup log: ' + ExpandConstant('{log}') + #13#10 + + 'Toolchain log (if PowerShell started): %LOCALAPPDATA%\Rustic Game Engine\logs\toolchain-install.log', + mbError, MB_OK, IDOK); + end; + if Failure = '' then + WizardForm.StatusLabel.Caption := 'Selected gameplay language toolchains are installed.'; WizardForm.Refresh; end; procedure CurStepChanged(CurStep: TSetupStep); -var - CompletedCount: Integer; - TotalCount: Integer; - Failures: String; begin - if CurStep = ssPostInstall then - begin - TotalCount := SelectedLanguageCount(); - if TotalCount > 0 then - begin - CompletedCount := 0; - Failures := ''; - WizardForm.ProgressGauge.Min := 0; - WizardForm.ProgressGauge.Max := TotalCount; - WizardForm.ProgressGauge.Position := 0; - - if WizardIsComponentSelected('languages\python') and not IsLanguageToolchainInstalled('Python') then - InstallLanguageToolchain('Python', 'Python 3.14', CompletedCount, TotalCount, Failures); - if WizardIsComponentSelected('languages\csharp') and not IsLanguageToolchainInstalled('CSharp') then - InstallLanguageToolchain('CSharp', '.NET SDK 10 for C#', CompletedCount, TotalCount, Failures); - if WizardIsComponentSelected('languages\ccpp') and not IsLanguageToolchainInstalled('CCpp') then - InstallLanguageToolchain('CCpp', 'LLVM for C and C++', CompletedCount, TotalCount, Failures); - if WizardIsComponentSelected('languages\java') and not IsLanguageToolchainInstalled('Java') then - InstallLanguageToolchain('Java', 'Microsoft OpenJDK 21', CompletedCount, TotalCount, Failures); - if WizardIsComponentSelected('languages\php') and not IsLanguageToolchainInstalled('Php') then - InstallLanguageToolchain('Php', 'PHP 8.4 CLI', CompletedCount, TotalCount, Failures); - - WizardForm.StatusLabel.Caption := 'Finished installing gameplay language toolchains.'; - WizardForm.Refresh; - - if Failures <> '' then - MsgBox(Format( - 'Rustic Game Engine was installed, but these optional language toolchains could not be installed: %s.' + #13#10 + #13#10 + - 'You can retry from Language Toolchain Manager in the Start menu.' + #13#10 + - 'Details: %s', [Failures, ExpandConstant('{localappdata}\Rustic Game Engine\logs\toolchain-install.log')]), - mbError, MB_OK); - end; - if (TotalCount = 0) and AnyOptionalLanguageSelected() then - begin - WizardForm.StatusLabel.Caption := 'Selected gameplay language toolchains are already installed.'; - WizardForm.Refresh; - end; - end; + if CurStep = ssPostInstall then InstallSelectedLanguageToolchains(); end; function NeedsAddPath(Param: string): Boolean; diff --git a/Engine/tools/manage-language-toolchains.ps1 b/Engine/tools/manage-language-toolchains.ps1 index d10efdeb..fc29a5df 100644 --- a/Engine/tools/manage-language-toolchains.ps1 +++ b/Engine/tools/manage-language-toolchains.ps1 @@ -2,20 +2,13 @@ param( [string]$Languages, [string]$LogPath, - [switch]$CheckOnly + [switch]$CheckOnly, + [switch]$Setup ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -if (-not [string]::IsNullOrWhiteSpace($LogPath)) { - $logDirectory = Split-Path -Parent $LogPath - if ($logDirectory) { - New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null - } - Start-Transcript -Path $LogPath -Append | Out-Null -} - $catalog = [ordered]@{ Python = [pscustomobject]@{ Name = 'Python 3.14'; Package = 'Python.Python.3.14'; Commands = @('python.exe', 'py.exe') } CSharp = [pscustomobject]@{ Name = '.NET SDK 10 for C#'; Package = 'Microsoft.DotNet.SDK.10'; Commands = @('dotnet.exe') } @@ -57,6 +50,14 @@ function Read-LanguageSelection { return @($selected | Select-Object -Unique) } +function Assert-WinGetInitialized([int]$ExitCode) { + # Stop the whole batch if the host cannot initialize, instead of launching + # the same broken executable again for each selected language. + if ($ExitCode -in @(142, -1073741502, 3221225794)) { + throw "Windows Package Manager failed to initialize (exit code $ExitCode). Repair Microsoft App Installer before retrying." + } +} + function Test-ToolchainInstalled($Entry) { foreach ($commandName in $Entry.Commands) { if (Get-Command $commandName -ErrorAction SilentlyContinue) { return $true } @@ -71,55 +72,83 @@ function Test-ToolchainInstalled($Entry) { $wingetPath = if ($wingetCommand -is [string]) { $wingetCommand } else { $wingetCommand.Source } & $wingetPath list --id $Entry.Package --exact --source winget ` - --disable-interactivity --accept-source-agreements *> $null + --disable-interactivity --accept-source-agreements | Out-Host + Assert-WinGetInitialized $LASTEXITCODE return $LASTEXITCODE -eq 0 } -$selectedLanguages = if ([string]::IsNullOrWhiteSpace($Languages)) { - @(Read-LanguageSelection) -} -else { - @($Languages.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -} -$selectedLanguages = @($selectedLanguages) - -if ($selectedLanguages.Count -eq 0) { - Write-Host 'No external language toolchains selected.' - exit 0 -} +$exitCode = 0 +$transcriptStarted = $false +try { + if ($Setup -and [string]::IsNullOrWhiteSpace($LogPath)) { + # Resolve this in the original user's process, not elevated Setup's profile. + $LogPath = Join-Path $env:LOCALAPPDATA 'Rustic Game Engine\logs\toolchain-install.log' + } + if (-not [string]::IsNullOrWhiteSpace($LogPath)) { + $logDirectory = Split-Path -Parent $LogPath + if ($logDirectory) { + New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + } + Start-Transcript -Path $LogPath -Append | Out-Null + $transcriptStarted = $true + } -foreach ($language in $selectedLanguages) { - if (-not $catalog.Contains($language)) { throw "Unknown language selection '$language'." } -} + if ($Setup -and [string]::IsNullOrWhiteSpace($Languages)) { + throw 'Setup must supply the selected languages; interactive selection is unavailable.' + } + $selectedLanguages = if ([string]::IsNullOrWhiteSpace($Languages)) { + @(Read-LanguageSelection) + } + else { + @($Languages.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + $selectedLanguages = @($selectedLanguages | Select-Object -Unique) -if ($CheckOnly) { foreach ($language in $selectedLanguages) { - if (-not (Test-ToolchainInstalled $catalog[$language])) { exit 1 } + if (-not $catalog.Contains($language)) { throw "Unknown language selection '$language'." } } - exit 0 -} -$winget = Find-WinGet -$failures = [System.Collections.Generic.List[string]]::new() -foreach ($language in $selectedLanguages) { - $entry = $catalog[$language] - if (Test-ToolchainInstalled $entry) { - Write-Host "$($entry.Name) is already installed." - continue + if ($CheckOnly) { + foreach ($language in $selectedLanguages) { + if (-not (Test-ToolchainInstalled $catalog[$language])) { $exitCode = 1 } + } } + else { + $winget = $null + $failures = [System.Collections.Generic.List[string]]::new() + foreach ($language in $selectedLanguages) { + $entry = $catalog[$language] + Write-Host "Checking $($entry.Name)..." + if (Test-ToolchainInstalled $entry) { + Write-Host "$($entry.Name) is already installed." + continue + } + + if (-not $winget) { $winget = Find-WinGet } + Write-Host "Installing $($entry.Name)..." + & $winget install --id $entry.Package --exact --source winget --silent ` + --disable-interactivity --accept-source-agreements --accept-package-agreements | Out-Host + $packageExitCode = $LASTEXITCODE + Write-Host "WinGet exit code: $packageExitCode" + Assert-WinGetInitialized $packageExitCode + if ($packageExitCode -ne 0) { + $failures.Add("$($entry.Name) (WinGet exit code $packageExitCode)") + } + } + + if ($failures.Count -gt 0) { + throw ("The following toolchains could not be installed: " + ($failures -join ', ') + '.') + } - Write-Host "Installing $($entry.Name)..." - & $winget install --id $entry.Package --exact --source winget --silent ` - --disable-interactivity --accept-source-agreements --accept-package-agreements - if ($LASTEXITCODE -ne 0) { - $failures.Add("$($entry.Name) (WinGet exit code $LASTEXITCODE)") + Write-Host 'Selected gameplay language toolchains are installed.' + Write-Host 'Restart Rustic Game Engine if it was already open so it can refresh PATH.' } } - -if ($failures.Count -gt 0) { - Write-Error ("The following toolchains could not be installed: " + ($failures -join ', ') + '.') - exit 1 +catch { + Write-Error $_ -ErrorAction Continue + $exitCode = 1 } - -Write-Host 'Selected gameplay language toolchains are installed.' -Write-Host 'Restart Rustic Game Engine if it was already open so it can refresh PATH.' +finally { + if ($transcriptStarted) { Stop-Transcript | Out-Null } +} +exit $exitCode diff --git a/Engine/tools/test-language-toolchains.ps1 b/Engine/tools/test-language-toolchains.ps1 new file mode 100644 index 00000000..bb4dbd0c --- /dev/null +++ b/Engine/tools/test-language-toolchains.ps1 @@ -0,0 +1,151 @@ +[CmdletBinding()] +param([string]$InstallerPath) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Run with Windows PowerShell 5.1, the host used by the shipped installer. +if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw 'These tests require Windows.' +} + +$manager = Join-Path $PSScriptRoot 'manage-language-toolchains.ps1' +$powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('rustic-toolchains-' + [guid]::NewGuid()) +$mockBin = Join-Path $testRoot 'bin' +New-Item -ItemType Directory -Path $mockBin -Force | Out-Null + +# A real native executable exercises native exit codes, including signed NTSTATUS. +# It never installs software or accesses the network. +Add-Type -OutputAssembly (Join-Path $mockBin 'winget.exe') -OutputType ConsoleApplication -TypeDefinition @' +using System; +using System.IO; +public class MockWinGet { + public static int Main(string[] args) { + File.AppendAllText(Environment.GetEnvironmentVariable("RUSTIC_TEST_CALLS"), + string.Join(" ", args) + Environment.NewLine); + string mode = Environment.GetEnvironmentVariable("RUSTIC_TEST_MODE"); + Console.WriteLine("Mock WinGet: " + string.Join(" ", args)); + if (args[0] == "list") { + if (mode == "fatal-list") return unchecked((int)0xC0000142); + if (mode == "fatal-list-142") return 142; + return mode == "installed" ? 0 : 1; + } + if (mode == "fatal-install") return unchecked((int)0xC0000142); + if (mode == "package-failure" && Array.IndexOf(args, "PHP.PHP.8.4") >= 0) return 9; + return 0; + } +} +'@ + +function Assert-Equal($Actual, $Expected, [string]$Message) { + if ($Actual -ne $Expected) { throw "$Message (expected $Expected, got $Actual)" } +} + +function New-TestProcess([string]$Mode) { + $caseRoot = Join-Path $testRoot ($Mode + '-' + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $caseRoot | Out-Null + $info = [System.Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + # Isolate discovery from the runner's installed toolchains and App Installer. + $info.EnvironmentVariables['PATH'] = $mockBin + $info.EnvironmentVariables['LOCALAPPDATA'] = $caseRoot + $info.EnvironmentVariables['RUSTIC_TEST_MODE'] = $Mode + $info.EnvironmentVariables['RUSTIC_TEST_CALLS'] = Join-Path $caseRoot 'calls.txt' + return $info +} + +function Get-Calls($Info) { + $path = $Info.EnvironmentVariables['RUSTIC_TEST_CALLS'] + if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path } +} + +function Invoke-ManagerCase([string]$Mode, [string]$Languages, [int]$ExpectedCode, + [int]$ExpectedCalls, [string]$ExpectedLog, [switch]$CheckOnly) { + $info = New-TestProcess $Mode + $info.FileName = $powershell + $info.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + + $manager + '" -Languages "' + $Languages + '"' + if ($CheckOnly) { $info.Arguments += ' -CheckOnly' } else { $info.Arguments += ' -Setup' } + $process = [System.Diagnostics.Process]::Start($info) + try { + if (-not $process.WaitForExit(60000)) { $process.Kill(); throw "$Mode timed out" } + Assert-Equal $process.ExitCode $ExpectedCode "$Mode exit code" + } + finally { $process.Dispose() } + Assert-Equal @(Get-Calls $info).Count $ExpectedCalls "$Mode WinGet invocation count" + $log = Join-Path $info.EnvironmentVariables['LOCALAPPDATA'] 'Rustic Game Engine\logs\toolchain-install.log' + if ($CheckOnly) { + Assert-Equal (Test-Path -LiteralPath $log) $false 'CheckOnly must not create a transcript' + } + else { + $text = Get-Content -LiteralPath $log -Raw + if (-not $text.Contains($ExpectedLog)) { throw "$Mode transcript did not contain '$ExpectedLog'" } + } + Write-Host "PASS: $Mode ($Languages)" +} + +function Invoke-InstallerCase([string]$Mode, [string]$Components, [int]$ExpectedCalls, + [bool]$ExpectFailure) { + $info = New-TestProcess $Mode + $info.FileName = (Resolve-Path -LiteralPath $InstallerPath).Path + $caseRoot = $info.EnvironmentVariables['LOCALAPPDATA'] + $installRoot = Join-Path $caseRoot 'app' + $setupLog = Join-Path $caseRoot 'setup.log' + $info.Arguments = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER /DIR="' + + $installRoot + '" /LOG="' + $setupLog + '" /COMPONENTS="' + $Components + '"' + $process = [System.Diagnostics.Process]::Start($info) + try { + if (-not $process.WaitForExit(120000)) { $process.Kill(); throw "$Mode Setup timed out" } + Assert-Equal $process.ExitCode 0 "$Mode Setup must finish installing the engine" + } + finally { $process.Dispose() } + Assert-Equal (Test-Path (Join-Path $installRoot 'rustic-project-manager.exe')) $true 'Engine installed' + Assert-Equal @(Get-Calls $info).Count $ExpectedCalls "$Mode Setup WinGet invocation count" + $log = Get-Content -LiteralPath $setupLog -Raw + Assert-Equal ($log.Contains('Language Toolchain Manager exited with code')) $ExpectFailure 'Setup failure diagnostic' + $transcript = Join-Path $caseRoot 'Rustic Game Engine\logs\toolchain-install.log' + if ($Components -eq 'engine,languages\bundled') { + Assert-Equal (Test-Path $transcript) $false 'Bundled-only Setup must not launch PowerShell' + } + else { + # One transcript header means all selections shared one PowerShell process. + $text = Get-Content -LiteralPath $transcript -Raw + Assert-Equal ([regex]::Matches($text, 'Windows PowerShell transcript start').Count) 1 'Setup PowerShell launch count' + } + Write-Host "PASS: packaged Setup $Mode" +} + +try { + $all = 'Python,CSharp,CCpp,Java,Php' + Invoke-ManagerCase 'installed' $all 0 5 'already installed' + Invoke-ManagerCase 'missing' $all 0 10 'Selected gameplay language toolchains are installed.' + Invoke-ManagerCase 'fatal-list' $all 1 1 'failed to initialize (exit code -1073741502)' + Invoke-ManagerCase 'fatal-list-142' $all 1 1 'failed to initialize (exit code 142)' + Invoke-ManagerCase 'fatal-install' $all 1 2 'failed to initialize (exit code -1073741502)' + Invoke-ManagerCase 'package-failure' 'Php,Python' 1 4 'PHP 8.4 CLI (WinGet exit code 9)' + Invoke-ManagerCase 'missing' 'Python,Python' 0 2 'Selected gameplay language toolchains are installed.' + Invoke-ManagerCase 'installed' 'Python' 0 1 '' -CheckOnly + Invoke-ManagerCase 'missing' 'Python' 1 1 '' -CheckOnly + Invoke-ManagerCase 'missing' 'Unknown' 1 0 "Unknown language selection 'Unknown'" + + # An already-installed toolchain does not require WinGet to be present. + Copy-Item (Join-Path $mockBin 'winget.exe') (Join-Path $mockBin 'python.exe') + Rename-Item (Join-Path $mockBin 'winget.exe') 'winget-disabled.exe' + Invoke-ManagerCase 'installed' 'Python' 0 0 'already installed' + Remove-Item (Join-Path $mockBin 'python.exe') + Invoke-ManagerCase 'missing' 'Python' 1 0 'Windows Package Manager is unavailable.' + Rename-Item (Join-Path $mockBin 'winget-disabled.exe') 'winget.exe' + + if ($InstallerPath) { + $components = 'engine,languages\bundled,languages\python,languages\csharp,languages\ccpp,languages\java,languages\php' + Invoke-InstallerCase 'missing' $components 10 $false + Invoke-InstallerCase 'fatal-list' $components 1 $true + Invoke-InstallerCase 'fatal-install' $components 2 $true + Invoke-InstallerCase 'missing' 'engine,languages\bundled' 0 $false + } +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force +} From da9671151f8fe508c855374a930cabba38236f22 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Tue, 13 Oct 2026 21:09:21 +0000 Subject: [PATCH 2/4] Validate Inno script before building engine and fix argument formatting --- Engine/README.md | 4 ++++ Engine/installer/RusticGameEngine.iss | 8 ++++---- Engine/tools/build-windows-installer.ps1 | 6 ++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Engine/README.md b/Engine/README.md index ecacd73d..3ac2c39a 100644 --- a/Engine/README.md +++ b/Engine/README.md @@ -70,6 +70,10 @@ removed through Windows Installed Apps. Because it is a desktop application, its folder is not added to `PATH` by default; pass `-InstallDirectoryOnPath` to select that installer option by default. +The builder validates the installer script with Inno Setup before compiling the +engine. This preflight omits the unbuilt binaries and disables output; it cannot +produce a distributable installer. + Setup checks and installs all selected optional toolchains in one hidden, noninteractive PowerShell process as the original user. Already-installed toolchains are skipped. If PowerShell cannot start, or WinGet returns an initialization failure diff --git a/Engine/installer/RusticGameEngine.iss b/Engine/installer/RusticGameEngine.iss index ecbbbb90..59dcbd48 100644 --- a/Engine/installer/RusticGameEngine.iss +++ b/Engine/installer/RusticGameEngine.iss @@ -72,12 +72,14 @@ Source: "..\licenses\ogg-BSD-3-Clause.txt"; DestDir: "{app}\licenses"; Flags: ig Source: "..\licenses\opus-pure-BSD-3-Clause.txt"; DestDir: "{app}\licenses"; Flags: ignoreversion Source: "..\licenses\opus-pure-ATTRIBUTION.md"; DestDir: "{app}\licenses"; Flags: ignoreversion Source: "..\licenses\Audio-Decoders-NOTICE.md"; DestDir: "{app}\licenses"; Flags: ignoreversion +#ifndef InstallerPreflight Source: "..\target\distribution\rustic-luau-host.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\target\distribution\rustic-agent-backend.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\target\distribution\rustic-project-manager.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\target\distribution\rustic-editor.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\target\distribution\rustic-runtime.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\target\distribution\rustic-asset-worker.exe"; DestDir: "{app}"; Flags: ignoreversion +#endif Source: "..\tools\manage-language-toolchains.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion [Icons] @@ -135,11 +137,9 @@ begin ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode ); if not Started then - Failure := Format('Could not start PowerShell: Windows error %d (%s).', - [ResultCode, SysErrorMessage(ResultCode)]) + Failure := Format('Could not start PowerShell: Windows error %d (%s).', [ResultCode, SysErrorMessage(ResultCode)]) else if ResultCode <> 0 then - Failure := Format('Language Toolchain Manager exited with code %d (0x%.8x).', - [ResultCode, ResultCode]); + Failure := Format('Language Toolchain Manager exited with code %d (0x%.8x).', [ResultCode, ResultCode]); except Failure := 'Could not start Language Toolchain Manager: ' + GetExceptionMessage; end; diff --git a/Engine/tools/build-windows-installer.ps1 b/Engine/tools/build-windows-installer.ps1 index 396af2be..c1e219a3 100644 --- a/Engine/tools/build-windows-installer.ps1 +++ b/Engine/tools/build-windows-installer.ps1 @@ -73,6 +73,12 @@ if (-not (Test-Path -LiteralPath $iscc -PathType Leaf)) { Push-Location $workspaceRoot try { + # Compile Setup's sections and Pascal code before the expensive Cargo build. + # /O- prevents producing a preflight installer; only unbuilt binaries are omitted. + Write-Host 'Validating the Windows installer script...' + & $iscc '/O-' '/DInstallerPreflight=1' 'installer\RusticGameEngine.iss' + if ($LASTEXITCODE -ne 0) { throw "Inno Setup preflight failed with exit code $LASTEXITCODE." } + Write-Host 'Building bundled Luau API host...' & $cargo build --locked --manifest-path 'apps/luau-host/Cargo.toml' --profile distribution --target-dir 'target' if ($LASTEXITCODE -ne 0) { throw "Luau host build failed with exit code $LASTEXITCODE." } From 354b1ae84ebeced890ca8bb161f129853815b415 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Tue, 13 Oct 2026 21:10:36 +0000 Subject: [PATCH 3/4] Run Windows toolchain regression checks before compiling engine --- Engine/tools/build-windows-installer.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Engine/tools/build-windows-installer.ps1 b/Engine/tools/build-windows-installer.ps1 index c1e219a3..89d8c991 100644 --- a/Engine/tools/build-windows-installer.ps1 +++ b/Engine/tools/build-windows-installer.ps1 @@ -79,6 +79,10 @@ try { & $iscc '/O-' '/DInstallerPreflight=1' 'installer\RusticGameEngine.iss' if ($LASTEXITCODE -ne 0) { throw "Inno Setup preflight failed with exit code $LASTEXITCODE." } + Write-Host 'Testing the toolchain manager with Windows PowerShell...' + & powershell -NoProfile -ExecutionPolicy Bypass -File 'tools\test-language-toolchains.ps1' + if ($LASTEXITCODE -ne 0) { throw "Toolchain manager tests failed with exit code $LASTEXITCODE." } + Write-Host 'Building bundled Luau API host...' & $cargo build --locked --manifest-path 'apps/luau-host/Cargo.toml' --profile distribution --target-dir 'target' if ($LASTEXITCODE -ne 0) { throw "Luau host build failed with exit code $LASTEXITCODE." } From e71c099ec4544f60bcee87c1bc449a2933aac938 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Tue, 13 Oct 2026 21:13:56 +0000 Subject: [PATCH 4/4] Log each failed package directly before the aggregated error --- Engine/tools/manage-language-toolchains.ps1 | 4 +++- Engine/tools/test-language-toolchains.ps1 | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Engine/tools/manage-language-toolchains.ps1 b/Engine/tools/manage-language-toolchains.ps1 index fc29a5df..ba3e14d0 100644 --- a/Engine/tools/manage-language-toolchains.ps1 +++ b/Engine/tools/manage-language-toolchains.ps1 @@ -132,7 +132,9 @@ try { Write-Host "WinGet exit code: $packageExitCode" Assert-WinGetInitialized $packageExitCode if ($packageExitCode -ne 0) { - $failures.Add("$($entry.Name) (WinGet exit code $packageExitCode)") + $failure = "$($entry.Name) (WinGet exit code $packageExitCode)" + Write-Host "Failed: $failure" + $failures.Add($failure) } } diff --git a/Engine/tools/test-language-toolchains.ps1 b/Engine/tools/test-language-toolchains.ps1 index bb4dbd0c..32a7cc78 100644 --- a/Engine/tools/test-language-toolchains.ps1 +++ b/Engine/tools/test-language-toolchains.ps1 @@ -81,7 +81,10 @@ function Invoke-ManagerCase([string]$Mode, [string]$Languages, [int]$ExpectedCod } else { $text = Get-Content -LiteralPath $log -Raw - if (-not $text.Contains($ExpectedLog)) { throw "$Mode transcript did not contain '$ExpectedLog'" } + if (-not $text.Contains($ExpectedLog)) { + Write-Host $text + throw "$Mode transcript did not contain '$ExpectedLog'" + } } Write-Host "PASS: $Mode ($Languages)" }