using WiiCompiled.Setup.Common; namespace WiiCompiled.Setup.Windows; internal sealed class InstallerEngine { private readonly IInstallReporter _reporter; public InstallerEngine(IInstallReporter reporter) => _reporter = reporter; public async Task InstallAsync(InstallOptions options, CancellationToken cancellationToken = default) { var installDirectory = Path.GetFullPath(options.InstallDirectory); ValidateInstallDirectory(installDirectory); InputValidation.ValidateExtension(options.GamePath); var parent = Directory.GetParent(installDirectory)?.FullName ?? throw new InvalidOperationException("The installation directory has no parent."); Directory.CreateDirectory(parent); using var operationLock = InstallOperationLock.Acquire(installDirectory, _reporter); var existing = new Installation(installDirectory); PortableInstallHealing.HealMovedInstall(existing, _reporter); // Capture this before publishing anything. An existing installation may be repaired or // updated by this invocation, but those operations must not recreate the user's shortcuts. var firstInstall = !existing.IsPresent; var previousState = existing.ReadInstallState(); using var scratch = Directory.Exists(installDirectory) ? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter) : InstallScratchSpace.CreateSibling(installDirectory, _reporter); var staging = scratch.Root; const long payloadStagingAllowance = 2L * 1024 * 1024 * 1024; FileSystemUtilities.EnsureFreeSpace(installDirectory, payloadStagingAllowance, "Setup"); using var payload = PayloadArchive.OpenCurrent(); var manifest = payload.ReadManifest(); var toolkit = InstalledLayout.Toolkit(staging); var workspace = InstalledLayout.Workspace(staging); var candidateToolkitFingerprint = manifest.ToolkitFingerprint; var candidateToolkitPackageFingerprint = manifest.ToolkitPackageFingerprint; var candidateRuntimeAssetsFingerprint = manifest.RuntimeAssetsFingerprint; var installedToolkitFingerprint = existing.IsPresent ? existing.ResolveToolkitFingerprint() : ""; var installedToolkitPackageFingerprint = existing.IsPresent ? existing.ResolveToolkitPackageFingerprint() : ""; var reusableGameAssets = FindReusableGameAssets(existing, manifest); var sameToolkit = existing.IsPresent && reusableGameAssets is not null && installedToolkitFingerprint.Equals(candidateToolkitFingerprint, StringComparison.Ordinal); var samePackageContent = existing.IsPresent && installedToolkitPackageFingerprint.Equals( candidateToolkitPackageFingerprint, StringComparison.Ordinal); var sameToolkitPackage = sameToolkit && samePackageContent; var hadRetro = existing.HasRetroProduct || previousState?.RetroRewindInstalled == true; var runtimeAssetsCurrent = sameToolkit && RuntimeAssetsAreCurrent(existing, candidateRuntimeAssetsFingerprint, cancellationToken); var installedNodTool = Path.Combine(existing.ToolkitDirectory, "nodtool.exe"); var extractToolkit = MustRefreshToolkit(sameToolkit, samePackageContent, File.Exists(installedNodTool)); var extractWorkspace = !sameToolkit || !runtimeAssetsCurrent; _reporter.Progress(InstallStages.ExtractToolkit, "Inspecting the release's local recompilation toolkit...", 1); if (extractToolkit) payload.ExtractDirectory(InstalledLayout.ToolkitDirectoryName, toolkit); if (extractWorkspace) { payload.ExtractDirectory(InstalledLayout.WorkspaceDirectoryName, workspace); WorkspaceTimestamps.MarkChangedFiles(existing.WorkspaceDirectory, workspace, _reporter.Diagnostic, cancellationToken); } payload.ExtractEntry("host/WiiCompiled-Setup.exe", Path.Combine(staging, ProductInfo.SetupCopyName)); payload.ExtractDirectory("licenses", Path.Combine(staging, "licenses")); payload.ExtractEntry(InstalledLayout.PayloadManifestFileName, Path.Combine(staging, InstalledLayout.PayloadManifestFileName)); var nodTool = extractToolkit ? Path.Combine(toolkit, "nodtool.exe") : installedNodTool; _reporter.Progress(InstallStages.Validate, "Checking the Wii disc image...", 2); var header = await InputValidation.ReadDiscHeaderAsync(nodTool, options.GamePath, cancellationToken); InputValidation.EnsureCompatibleDisc(header, manifest); var canonicalRetroRoot = options.RetroDirectoryPath is null ? null : RetroRewindSource.ResolveRetroRewind6(options.RetroDirectoryPath); ValidateRetroOptions(canonicalRetroRoot, options.RetroWfcPayloadMode, manifest); var retroCompileInputs = canonicalRetroRoot is null ? null : SnapshotRetroRewindCompileInputs(canonicalRetroRoot, Path.Combine(staging, "compile-inputs"), cancellationToken); if (options.Portable) { var portableRoot = PortableRoot.Create(parent); _reporter.Diagnostic($"Installing as a portable installation under {portableRoot}."); } if (sameToolkit) { var desiredInputDrift = ProductRepairService.FindDesiredInputDrift(existing, candidateToolkitFingerprint, manifest.ExpectedDolSha256, manifest.ExpectedRelSha256); if (desiredInputDrift.Retro && retroCompileInputs is null) throw new InvalidOperationException( "This release changes the clean game inputs used by the installed Retro Rewind product. " + "Supply Wheel Wizard's current Retro Rewind folder explicitly so it can be rebuilt safely."); var reconciliation = await ReconcileSameToolkitAsync(existing, retroCompileInputs, canonicalRetroRoot, options.RetroWfcPayloadMode, Path.Combine(staging, "repair"), manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, cancellationToken); var remainingCancellation = reconciliation.PublicationCommitted ? CancellationToken.None : cancellationToken; if (!runtimeAssetsCurrent) runtimeAssetsCurrent = RuntimeAssetsAreCurrent(existing, candidateRuntimeAssetsFingerprint, remainingCancellation); remainingCancellation.ThrowIfCancellationRequested(); EnsureReconciliationCurrent(existing, candidateToolkitFingerprint, retroCompileInputs, manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, reconciliation.CachedRetroWfcPayloadMatches); var updatedState = BuildInstallState(existing.ReadInstallState(), installDirectory, manifest, retroCompileInputs, canonicalRetroRoot, options.RetroWfcPayloadMode); PrepareMetadata(staging, manifest, updatedState); remainingCancellation.ThrowIfCancellationRequested(); var releaseEntries = new List(); if (!sameToolkitPackage) AddComponent(releaseEntries, staging, installDirectory, InstalledLayout.ToolkitDirectoryName); if (!runtimeAssetsCurrent) AddRuntimeAssetPublicationEntries(releaseEntries, staging, installDirectory, updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint, remainingCancellation); Publish(staging, installDirectory, canonicalRetroRoot, updatedState, releaseEntries, remainingCancellation, createShortcuts: firstInstall); return; } if (hadRetro && retroCompileInputs is null) { throw new InvalidOperationException( "The installed Retro Rewind product must be produced again. Supply Wheel Wizard's " + "current Retro Rewind folder explicitly so it can be rebuilt safely."); } EnsureSufficientRemainingDiskSpace(installDirectory, needsExtractedDisc: reusableGameAssets is null, needsLocalBuild: true); if (reusableGameAssets is null) { await ExtractGameAssetsAsync(nodTool, options.GamePath, Path.Combine(staging, "GameAssets"), manifest, cancellationToken); } await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest, previousState, options, canonicalRetroRoot, retroCompileInputs, publishGameAssets: reusableGameAssets is null, createShortcuts: firstInstall, cancellationToken: cancellationToken); } internal static bool MustRefreshToolkit(bool sameToolkit, bool samePackageContent, bool nodToolPresent) => !sameToolkit || !samePackageContent || !nodToolPresent; private static void AddComponent(List entries, string staging, string installDirectory, string name) => entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, name), Path.Combine(installDirectory, name))); /// /// Build-produced workspace directories (not shipped in a release) grafted from the installed workspace into /// the staged one, carrying the base translation, native build dir, verified inputs, and Retro-WFC payload cache /// across a toolkit update. Each is re-validated by the build, so stale caches degrade to a clean rebuild, never reuse. /// private static readonly string[] WorkspaceCacheDirectories = ["generated", "build", "native-build", "Assets", "PulsarPacks"]; private async Task PublishToolkitAndReconcileProductsAsync(Installation existing, string staging, string stagedWorkspace, PayloadManifest manifest, InstallState? previousState, InstallOptions options, string? canonicalRetroRoot, RetroRewindCompileInputs? retroCompileInputs, bool publishGameAssets, bool createShortcuts, CancellationToken cancellationToken) { var installDirectory = existing.Root; foreach (var cacheDirectory in WorkspaceCacheDirectories) { cancellationToken.ThrowIfCancellationRequested(); var source = Path.Combine(existing.WorkspaceDirectory, cacheDirectory); if (!Directory.Exists(source)) continue; var destination = Path.Combine(stagedWorkspace, cacheDirectory); if (Directory.Exists(destination)) Directory.Delete(destination, recursive: true); Directory.Move(source, destination); } var state = BuildInstallState(previousState, installDirectory, manifest, compileInputs: null, canonicalRetroRoot, options.RetroWfcPayloadMode); PrepareMetadata(staging, manifest, state); cancellationToken.ThrowIfCancellationRequested(); var entries = new List(); AddComponent(entries, staging, installDirectory, InstalledLayout.ToolkitDirectoryName); AddComponent(entries, staging, installDirectory, InstalledLayout.WorkspaceDirectoryName); if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets"); Publish(staging, installDirectory, canonicalRetroRoot, state, entries, cancellationToken, progressPercent: 8, completionPercent: 10, createShortcuts: createShortcuts); _reporter.Progress(InstallStages.BuildBase, "Producing the installed products with the published toolkit...", 11); var reconciliation = await ReconcileSameToolkitAsync(existing, retroCompileInputs, canonicalRetroRoot, options.RetroWfcPayloadMode, repairScratch: null, manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, cancellationToken); EnsureReconciliationCurrent(existing, manifest.ToolkitFingerprint, retroCompileInputs, manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, reconciliation.CachedRetroWfcPayloadMatches); } private Task ReconcileSameToolkitAsync(Installation existing, RetroRewindCompileInputs? retroCompileInputs, string? canonicalRetroRoot, RetroWfcPayloadMode payloadMode, string? repairScratch, string expectedDolSha256, string expectedRelSha256, CancellationToken cancellationToken) { var repair = new ProductRepairService(existing, _reporter); var options = new ProductRepairService.ReconcileOptions { ScratchRoot = repairScratch, ExpectedDolSha256 = expectedDolSha256, ExpectedRelSha256 = expectedRelSha256, CanonicalRetroRewindRoot = canonicalRetroRoot }; return retroCompileInputs is null ? repair.RepairBaseAsync(options, cancellationToken) : repair.RepairRetroAsync(retroCompileInputs, payloadMode, options, cancellationToken); } private static void EnsureReconciliationCurrent(Installation installation, string toolkitFingerprint, RetroRewindCompileInputs? retroCompileInputs, string expectedDolSha256, string expectedRelSha256, bool? cachedRetroWfcPayloadMatches) { var baseState = installation.CheckBase(toolkitFingerprint); var retroState = installation.CheckRetroRewind(toolkitFingerprint, retroCompileInputs, canonicalError: null, cachedRetroWfcPayloadMatches); var desiredInputsCurrent = installation.ProductUsesGameInputs(installation.BaseDirectory, toolkitFingerprint, expectedDolSha256, expectedRelSha256) && (!installation.HasRetroProduct || installation.ProductUsesGameInputs(installation.RetroDirectory, toolkitFingerprint, expectedDolSha256, expectedRelSha256)); if (baseState.Status == ProductStatus.Current && !retroState.ActionRequired && desiredInputsCurrent) return; var details = string.Join(" ", new[] { baseState, retroState } .Where(state => state.ActionRequired) .Select(state => state.Detail) .Where(detail => !string.IsNullOrWhiteSpace(detail))); throw new InvalidOperationException( "Product reconciliation did not produce a current installation with the release's game inputs; " + "release metadata was not advanced." + (string.IsNullOrWhiteSpace(details) ? "" : " " + details)); } /// /// Publishes the caller's component entries together with the release metadata every install and /// update advances, then applies the runtime configuration this installation owns. /// private void Publish(string staging, string installDirectory, string? canonicalRetroRoot, InstallState state, List entries, CancellationToken cancellationToken, int progressPercent = 95, int completionPercent = 99, bool createShortcuts = false) { entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"), Path.Combine(installDirectory, "licenses"))); entries.Add(InstallTransactionEntry.File(Path.Combine(staging, ProductInfo.SetupCopyName), Path.Combine(installDirectory, ProductInfo.SetupCopyName))); entries.Add(InstallTransactionEntry.File( Path.Combine(staging, InstalledLayout.PayloadManifestFileName), Path.Combine(installDirectory, InstalledLayout.PayloadManifestFileName))); entries.Add(InstallTransactionEntry.File(Path.Combine(staging, ToolkitState.FileName), Path.Combine(installDirectory, ToolkitState.FileName))); entries.Add(InstallTransactionEntry.File( Path.Combine(staging, InstalledLayout.InstallStateFileName), Path.Combine(installDirectory, InstalledLayout.InstallStateFileName))); cancellationToken.ThrowIfCancellationRequested(); RunningProductGuard.EnsureProductsNotRunning(installDirectory); _reporter.Progress(InstallStages.Publish, "Publishing the completed installation...", progressPercent); var configPath = RuntimeConfiguration.ResolveConfigPath(installDirectory); var configSnapshot = RuntimeConfiguration.Capture(configPath); using var transaction = InstallTransaction.Begin(installDirectory, _reporter, entries.ToArray()); transaction.Publish(); transaction.RecordRuntimeConfigurationMutation(configSnapshot); RuntimeConfiguration.SetDvdRoot(configPath, Path.Combine(installDirectory, "GameAssets", "DATA")); // The runtime scans this directory live for its asset overlay, so it is recorded on every // operation that receives one and is never rewritten by a base-only operation. if (canonicalRetroRoot is not null) RuntimeConfiguration.SetRetroRewindRoot(configPath, canonicalRetroRoot); transaction.Commit(); try { // A portable installation is owned by the folder it lives in, not by this machine. Adding // a machine-wide uninstall entry for it would outlive the folder and, worse, would // overwrite the entry of a normal installation on the same account. if (PortableRoot.TryFind(installDirectory) is not null) { _reporter.Diagnostic( "Portable installation: no Windows uninstall entry was registered. Remove the folder, " + "or run the copied setup with --uninstall, to uninstall it."); } else { ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled); } if (createShortcuts) ShellIntegration.CreateShortcuts(installDirectory); } catch (Exception ex) { _reporter.Diagnostic("The installation succeeded, but Windows shell integration failed: " + ex.Message); } _reporter.Progress(InstallStages.Publish, "Installation complete.", completionPercent); } private static void PrepareMetadata(string staging, PayloadManifest manifest, InstallState state) { JsonState.Write(Path.Combine(staging, ToolkitState.FileName), new ToolkitState { ToolkitFingerprint = manifest.ToolkitFingerprint, ToolkitPackageFingerprint = manifest.ToolkitPackageFingerprint, RuntimeAssetsFingerprint = manifest.RuntimeAssetsFingerprint, TranslationFingerprint = manifest.TranslationFingerprint, NativeToolchainFingerprint = manifest.NativeToolchainFingerprint, ToolkitReleaseTag = manifest.ToolkitReleaseTag }); JsonState.Write(Path.Combine(staging, InstalledLayout.InstallStateFileName), state); } private static InstallState BuildInstallState(InstallState? previousState, string installDirectory, PayloadManifest manifest, RetroRewindCompileInputs? compileInputs, string? canonicalRetroRoot, RetroWfcPayloadMode payloadMode, RetroWfcPayloadSnapshot? retroWfcPayloadSnapshot = null) { // Preserve the pre-update object for runtime-configuration rollback/ownership decisions. var state = new InstallState { SchemaVersion = 1, SetupVersion = ProductInfo.Version, ProductVersion = manifest.ProductVersion, InstallDir = installDirectory, InstalledUtc = previousState?.InstalledUtc ?? DateTime.UtcNow, RetroRewindInstalled = previousState?.RetroRewindInstalled ?? false, ToolkitReleaseTag = manifest.ToolkitReleaseTag, DolSha256 = manifest.ExpectedDolSha256, RelSha256 = manifest.ExpectedRelSha256, RetroRewindCodePulSha256 = previousState?.RetroRewindCodePulSha256 ?? "", RetroRewindCompileInputsSha256 = previousState?.RetroRewindCompileInputsSha256 ?? "", RetroWfcPayloadMode = previousState?.RetroWfcPayloadMode ?? "", RetroWfcPayloadSha256 = previousState?.RetroWfcPayloadSha256 ?? "", RetroWfcPayloadLength = previousState?.RetroWfcPayloadLength ?? 0, RetroRewindRoot = canonicalRetroRoot is null ? previousState?.RetroRewindRoot ?? "" : Path.GetFullPath(canonicalRetroRoot) }; if (compileInputs is not null) { state.RetroRewindInstalled = true; state.RetroRewindCodePulSha256 = compileInputs.CodePulSha256; state.RetroRewindCompileInputsSha256 = compileInputs.CompileInputsSha256; state.RetroWfcPayloadMode = payloadMode == RetroWfcPayloadMode.Online ? "downloaded" : "skipped"; state.RetroWfcPayloadSha256 = payloadMode == RetroWfcPayloadMode.Online ? retroWfcPayloadSnapshot?.Sha256 ?? state.RetroWfcPayloadSha256 : ""; state.RetroWfcPayloadLength = payloadMode == RetroWfcPayloadMode.Online ? retroWfcPayloadSnapshot?.ByteLength ?? state.RetroWfcPayloadLength : 0; if (payloadMode == RetroWfcPayloadMode.Online && (string.IsNullOrWhiteSpace(state.RetroWfcPayloadSha256) || state.RetroWfcPayloadLength <= 0)) throw new InvalidDataException("The downloaded Retro-WFC payload snapshot is missing."); } return state; } /// /// Captures the canonical installation's compile inputs into operation-owned staging. Only /// Code.pul and the bundled Pulsar sources are copied; the asset tree stays where Wheel Wizard /// owns it and is read live by the runtime through retro_rewind_root. /// private RetroRewindCompileInputs SnapshotRetroRewindCompileInputs(string canonicalRetroRoot, string destination, CancellationToken cancellationToken) { _reporter.Progress(InstallStages.Validate, "Snapshotting the Retro Rewind compile inputs...", 3); return CompileInputsFingerprint.Snapshot(canonicalRetroRoot, destination, cancellationToken); } private bool RuntimeAssetsAreCurrent(Installation installation, string expectedFingerprint, CancellationToken cancellationToken) { var installedSource = ToolkitFingerprint.TryComputeRuntimeAssets(installation.Root, cancellationToken, _reporter.Diagnostic); if (!expectedFingerprint.Equals(installedSource, StringComparison.Ordinal)) return false; if (File.Exists(installation.BaseExecutable) && !ToolkitFingerprint.ProductRuntimeAssetsMatch(installation.BaseDirectory, expectedFingerprint, cancellationToken, _reporter.Diagnostic)) return false; return !installation.HasRetroProduct || ToolkitFingerprint.ProductRuntimeAssetsMatch(installation.RetroDirectory, expectedFingerprint, cancellationToken, _reporter.Diagnostic); } private void AddRuntimeAssetPublicationEntries(List entries, string staging, string installDirectory, bool includeRetro, string expectedFingerprint, CancellationToken cancellationToken) { // The identity was computed from this exact operation-owned payload extraction, so the // staged assets are published as they are; only repair has an installed source to re-verify. var sourceAssets = Path.Combine(InstalledLayout.Workspace(staging), "runtime", "assets"); var products = (includeRetro ? new[] { "Base", "RetroRewind" } : new[] { "Base" }) .Select(product => (product, Path.Combine(installDirectory, product))); RuntimeAssetPublication.AddEntries(entries, sourceAssets, Path.Combine(staging, "runtime-asset-publication"), products, expectedFingerprint, "publication", _reporter.Diagnostic, cancellationToken); entries.Add(InstallTransactionEntry.Directory(sourceAssets, Path.Combine(InstalledLayout.Workspace(installDirectory), "runtime", "assets"))); } private static void ValidateRetroOptions(string? canonicalRetroRoot, RetroWfcPayloadMode mode, PayloadManifest manifest) { if (canonicalRetroRoot is null && mode != RetroWfcPayloadMode.NotApplicable) throw new InvalidOperationException( "A Retro-WFC payload option requires the canonical Retro Rewind folder."); if (canonicalRetroRoot is not null && mode == RetroWfcPayloadMode.NotApplicable) throw new InvalidOperationException( "Download the Retro-WFC payload, or explicitly skip the optional payload."); if (canonicalRetroRoot is not null && mode == RetroWfcPayloadMode.Online) InputValidation.ValidateRetroWfcPayloadUri(manifest.RetroWfcPayloadUri); } private string? FindReusableGameAssets(Installation existing, PayloadManifest manifest) { var dataRoot = existing.GameDataDirectory; // No fst.bin simply means nothing was extracted here before; anything past that gate is an // existing extraction that fails reuse, which the log must say before the expensive // re-extraction quietly repairs it. if (!File.Exists(Path.Combine(dataRoot, "sys", "fst.bin"))) return null; try { ValidateExtractedGame(dataRoot, manifest); return dataRoot; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) { _reporter.Diagnostic( "The installed game assets cannot be reused; the disc will be extracted again: " + ex.Message); return null; } } private async Task ExtractGameAssetsAsync(string nodTool, string gamePath, string destination, PayloadManifest manifest, CancellationToken cancellationToken) { _reporter.Progress(InstallStages.ExtractDisc, "Extracting the game disc. This is the longest preparation step...", 6); // Extracted straight into a "DATA" subfolder so the on-disk layout matches what // Installation.GameDataDirectory and every other reader of it already expect - nodtool // itself has no such wrapper (it extracts sys/+files/ directly to whatever is // given), so this is purely destination-side, not a nodtool convention. var dataRoot = Path.Combine(destination, "DATA"); var extraction = await ProcessRunner.RunAsync(nodTool, ["extract", Path.GetFullPath(gamePath), dataRoot, "-q"], line => { if (!string.IsNullOrWhiteSpace(line)) _reporter.Diagnostic(line); }, cancellationToken); if (extraction.ExitCode != 0) throw new InvalidDataException("Game extraction failed. " + extraction.CombinedOutput.Trim()); ValidateExtractedGame(dataRoot, manifest); } private static void ValidateExtractedGame(string dataRoot, PayloadManifest manifest) { var dol = Path.Combine(dataRoot, "sys", "main.dol"); var rel = Path.Combine(dataRoot, "files", "rel", "StaticR.rel"); var fst = Path.Combine(dataRoot, "sys", "fst.bin"); if (!File.Exists(dol) || !File.Exists(rel) || !File.Exists(fst)) throw new InvalidDataException("The disc extraction is missing required Mario Kart Wii files."); var dolHash = InputValidation.Sha256File(dol); var relHash = InputValidation.Sha256File(rel); if (!dolHash.Equals(manifest.ExpectedDolSha256, StringComparison.OrdinalIgnoreCase) || !relHash.Equals(manifest.ExpectedRelSha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException( "The disc is RMCP01 but does not match the supported clean PAL revision. " + "Patched or otherwise modified game code cannot be installed safely."); } } private static void ValidateInstallDirectory(string path) => FileSystemUtilities.EnsureUsableLocation(path, "The installation folder"); private static void EnsureSufficientRemainingDiskSpace(string installDirectory, bool needsExtractedDisc, bool needsLocalBuild) { const long extractedDiscAllowance = 5L * 1024 * 1024 * 1024; const long localBuildAllowance = 14L * 1024 * 1024 * 1024; const long safetyAllowance = 2L * 1024 * 1024 * 1024; // This preflight runs after the release payload and the compile-input snapshot have already // been staged. Count only work that can still allocate space from this point. var required = checked((needsExtractedDisc ? extractedDiscAllowance : 0) + (needsLocalBuild ? localBuildAllowance : 0) + safetyAllowance); FileSystemUtilities.EnsureFreeSpace(installDirectory, required, "Setup"); } }