Write-Host "Build Common Loading" $ErrorActionPreference = "Stop" Set-StrictMode -Version 3.0 $global:buildCommonSelfPath = split-path -parent $MyInvocation.MyCommand.Definition $cleanCookerOutput = Join-Path -Path $global:buildCommonSelfPath "clean_cooker_output.ps1" Write-Host "Sourcing $cleanCookerOutput" . $cleanCookerOutput # list of all native script packages $global:nativescriptpackages = @("XComGame", "Core", "Engine", "GFxUI", "AkAudio", "GameFramework", "UnrealEd", "GFxUIEditor", "IpDrv", "OnlineSubsystemPC", "OnlineSubsystemLive", "OnlineSubsystemSteamworks", "OnlineSubsystemPSN") $global:def_robocopy_args = @("/S", "/E", "/COPY:DAT", "/PURGE", "/MIR", "/NP", "/R:1000000", "/W:30") $global:invarCulture = [System.Globalization.CultureInfo]::InvariantCulture class BuildProject { [string] $modNameCanonical [string] $modNameFull [string] $projectRoot [string] $sdkPath [string] $gamePath [string] $finalModPath [string] $contentOptionsJsonFilename [long] $publishID = -1 [bool] $debug = $false [bool] $final_release = $false [string[]] $include = @() [string[]] $clean = @() [object[]] $preMakeHooks = @() # internals [hashtable] $macroDefs = @{} [object[]] $timings = @() # lazily set [string] $modSrcRoot [string] $modX2projPath [string] $devSrcRoot [string] $stagingPath [string] $xcomModPath [string] $commandletHostPath [string] $buildCachePath [string] $cookerOutputPath [string] $makeFingerprintsPath [string[]] $modScriptPackages [bool] $isHl [bool] $cookHL [PSCustomObject] $contentOptions [string] $sdkEngineIniPath [string] $sdkEngineIniContent BuildProject( [string]$mod, [string]$projectRoot, [string]$sdkPath, [string]$gamePath ){ $this.modNameFull = $mod $this.modNameCanonical = $mod -Replace '[\s;]','' $this.projectRoot = $projectRoot $this.sdkPath = $sdkPath $this.gamePath = $gamePath } [void]SetContentOptionsJsonFilename($filename) { $this.contentOptionsJsonFilename = $filename } [void]SetWorkshopID([long] $publishID) { if ($publishID -le 0) { ThrowFailure "publishID must be >0" } $this.publishID = $publishID } [void]EnableFinalRelease() { $this.final_release = $true $this._CheckFlags() } [void]EnableDebug() { $this.debug = $true $this._CheckFlags() } [void]AddPreMakeHook([Action[]] $action) { $this.preMakeHooks += $action } [void]AddToClean([string] $modName) { $this.clean += $modName } [void]IncludeSrc([string] $src) { if (!(Test-Path $src)) { ThrowFailure "include path $src doesn't exist" } $this.include += $src } [void]InvokeBuild() { try { $fullStopwatch = [Diagnostics.Stopwatch]::StartNew() $this._ConfirmPaths() $this._SetupUtils() $this._LoadContentOptions() if ($this._HasScriptPackages()) { $this._PerformStep({ ($_)._CleanAdditional() }, "Cleaning", "Cleaned", "additional mods") } $this._PerformStep({ ($_)._CopyModToSdk() }, "Mirroring", "Mirrored", "mod to SDK") $this._PerformStep({ ($_)._ConvertLocalization() }, "Converting", "Converted", "Localization UTF-8 -> UTF-16") if ($this._ShouldCompileBase()) { $this._PerformStep({ ($_)._CopyToSrc() }, "Populating", "Populated", "Development\Src folder") $this._PerformStep({ ($_)._RunPreMakeHooks() }, "Running", "Ran", "Pre-Make hooks") $this._PerformStep({ ($_)._CheckCleanCompiled() }, "Verifying", "Verified", "compiled script packages") $this._PerformStep({ ($_)._RunMakeBase() }, "Compiling", "Compiled", "base-game script packages") } if ($this._HasScriptPackages()) { $this._PerformStep({ ($_)._RunMakeMod() }, "Compiling", "Compiled", "mod script packages") } if ($this._ShouldCompileBase()) { $this._RecordCoreTimestamp() } if ($this._HasScriptPackages()) { if ($this.isHl) { if (-not $this.debug) { $this._PerformStep({ ($_)._RunCookHL() }, "Cooking", "Cooked", "Highlander packages") } else { Write-Host "Skipping HL cooking as debug build" } } $this._PerformStep({ ($_)._CopyScriptPackages() }, "Copying", "Copied", "compiled script packages") } # The shader step needs to happen before asset cooking - precompiler gets confused by some inlined materials $this._PerformStep({ ($_)._PrecompileShaders() }, "Precompiling", "Precompiled", "shaders") $this._PerformStep({ ($_)._RunCookAssets() }, "Cooking", "Cooked", "mod assets") # Do this last as there is no need for it earlier - the cooker obviously has access to the game assets # and precompiling shaders seems to do nothing (I assume they are included in the game's GlobalShaderCache) $this._PerformStep({ ($_)._CopyMissingUncooked() }, "Copying", "Copied", "requested uncooked packages") $this._PerformStep({ ($_)._FinalCopy() }, "Copying", "Copied", "built mod to game directory") $fullStopwatch.Stop() $this._ReportTimings($fullStopwatch) SuccessMessage "*** SUCCESS! ($(FormatElapsed $fullStopwatch.Elapsed)) ***" $this.modNameCanonical } catch { [System.Media.SystemSounds]::Hand.Play() throw } } [void]_PerformStep([scriptblock]$stepCallback, [string]$progressWord, [string]$completedWord, [string]$description) { Write-Host "$($progressWord) $($description)..." $sw = [Diagnostics.Stopwatch]::StartNew() # HACK: Set $_ for $stepCallback with Foreach-Object on only one object $this | ForEach-Object $stepCallback $sw.Stop() $record = [PSCustomObject]@{ Description = "$($progressWord) $($description)" Seconds = $sw.Elapsed.TotalSeconds } $this.timings += $record Write-Host -ForegroundColor DarkGreen "$($completedWord) $($description) in $(FormatElapsed $sw.Elapsed)" } [void]_ReportTimings([Diagnostics.Stopwatch]$fullStopwatch) { if (-not [string]::IsNullOrEmpty($env:X2MBC_REPORT_TIMINGS)) { $fullTime = $fullStopwatch.Elapsed.TotalSeconds $accountedTime = $this.timings | Measure-Object -Sum -Property Seconds | Select-Object -ExpandProperty Sum $this.timings += [PSCustomObject]@{ Description = "Total Duration" Seconds = $fullTime } $this.timings += [PSCustomObject]@{ Description = "Unaccounted time" Seconds = $fullTime - $accountedTime } $this.timings | Sort-Object -Descending -Property { $_.Seconds } | ForEach-Object { $_ | Add-Member -NotePropertyName "Share" -NotePropertyValue ($_.Seconds / $fullTime).ToString("0.00%", $global:invarCulture) $_.Seconds = $_.Seconds.ToString("0.00s", $global:invarCulture) $_ } | Format-Table | Out-String | Write-Host } } [void]_CheckFlags() { if ($this.debug -eq $true -and $this.final_release -eq $true) { ThrowFailure "-debug and -final_release cannot be used together" } } [void]_ConfirmPaths() { Write-Host "SDK Path: $($this.sdkPath)" Write-Host "Game Path: $($this.gamePath)" # Check if the user config is set up correctly if (([string]::IsNullOrEmpty($this.sdkPath) -or $this.sdkPath -eq '${config:xcom.highlander.sdkroot}') -or ([string]::IsNullOrEmpty($this.gamePath) -or $this.gamePath -eq '${config:xcom.highlander.gameroot}')) { ThrowFailure "Please set up user config xcom.highlander.sdkroot and xcom.highlander.gameroot" } elseif (!(Test-Path $this.sdkPath)) # Verify the SDK and game paths exist before proceeding { ThrowFailure ("The path '{}' doesn't exist. Please adjust the xcom.highlander.sdkroot variable in your user config and retry." -f $this.sdkPath) } elseif (!(Test-Path $this.gamePath)) { ThrowFailure ("The path '{}' doesn't exist. Please adjust the xcom.highlander.gameroot variable in your user config and retry." -f $this.gamePath) } } [void]_SetupUtils() { $this.modSrcRoot = "$($this.projectRoot)\$($this.modNameFull)" $this.modX2projPath = "$($this.modSrcRoot)\$($this.modNameFull).x2proj" $this.stagingPath = "$($this.sdkPath)\XComGame\Mods\$($this.modNameCanonical)" $this.xcomModPath = "$($this.stagingPath)\$($this.modNameCanonical).XComMod" $this.finalModPath = "$($this.gamePath)\XComGame\Mods\$($this.modNameCanonical)" $this.devSrcRoot = "$($this.sdkPath)\Development\Src" $this.commandletHostPath = "$($this.sdkPath)/binaries/Win64/XComGame.com" # build package lists we'll need later and delete as appropriate # the mod's packages $modSrcPath = "$($this.modSrcRoot)/Src" if (Test-Path $modSrcPath) { $this.modScriptPackages = @(Get-ChildItem "$($this.modSrcRoot)/Src" -Directory) } else { # No scripts to compile $this.modScriptPackages = @() } if (!$this._HasScriptPackages()) { if ($this.clean.Length -gt 0) { ThrowFailure "AddToClean is not supported when no script packages to compile" } if ($this.include.Length -gt 0) { ThrowFailure "IncludeSrc is not supported when no script packages to compile" } if ($this.preMakeHooks.Length -gt 0) { ThrowFailure "AddPreMakeHook is not supported when no script packages to compile" } if ($this.debug) { ThrowFailure "Debug build enabled but no script packages to compile" } } $this.isHl = $this._HasNativePackages() $this.cookHL = $this.isHl -and -not $this.debug if (-not $this.isHl -and $this.final_release) { ThrowFailure "-final_release only makes sense if the mod in question is a Highlander" } $this.cookerOutputPath = [io.path]::combine($this.sdkPath, 'XComGame', 'Published', 'CookedPCConsole') $this.buildCachePath = [io.path]::combine($this.projectRoot, 'BuildCache') if (!(Test-Path $this.buildCachePath)) { New-Item -ItemType "directory" -Path $this.buildCachePath } $this.sdkEngineIniPath = "$($this.sdkPath)/XComGame/Config/DefaultEngine.ini" $this.sdkEngineIniContent = Get-Content $this.sdkEngineIniPath | Out-String $this.makeFingerprintsPath = "$($this.sdkPath)\XComGame\lastBuildDetails.json" $lastBuildDetails = if (Test-Path $this.makeFingerprintsPath) { Get-Content $this.makeFingerprintsPath | ConvertFrom-Json } else { [PSCustomObject]@{} } @("buildMode", "globalsHash", "coreTimestamp") | ForEach-Object { if(-not (Get-Member -InputObject $lastBuildDetails -name $_ -Membertype Properties)) { $lastBuildDetails | Add-Member -NotePropertyName $_ -NotePropertyValue "unknown" } } $lastBuildDetails | ConvertTo-Json | Set-Content -Path $this.makeFingerprintsPath } [void]_LoadContentOptions() { Write-Host "Preparing content options" if ([string]::IsNullOrEmpty($this.contentOptionsJsonFilename)) { $this.contentOptions = [PSCustomObject]@{} } else { $contentOptionsJsonPath = Join-Path $this.modSrcRoot $this.contentOptionsJsonFilename if (!(Test-Path $contentOptionsJsonPath)) { ThrowFailure "ContentOptionsJsonPath $contentOptionsJsonPath doesn't exist" } $this.contentOptions = Get-Content $contentOptionsJsonPath | ConvertFrom-Json Write-Host "Loaded $($contentOptionsJsonPath)" } if (($this.contentOptions.PSobject.Properties | ForEach-Object {$_.Name}) -notcontains "missingUncooked") { Write-Host "No missing uncooked" $this.contentOptions | Add-Member -MemberType NoteProperty -Name 'missingUncooked' -Value @() } if (($this.contentOptions.PSobject.Properties | ForEach-Object {$_.Name}) -notcontains "sfStandalone") { Write-Host "No packages to make SF" $this.contentOptions | Add-Member -MemberType NoteProperty -Name 'sfStandalone' -Value @() } if (($this.contentOptions.PSobject.Properties | ForEach-Object {$_.Name}) -notcontains "sfMaps") { Write-Host "No umaps to cook" $this.contentOptions | Add-Member -MemberType NoteProperty -Name 'sfMaps' -Value @() } if (($this.contentOptions.PSobject.Properties | ForEach-Object {$_.Name}) -notcontains "sfCollectionMaps") { Write-Host "No collection maps to cook" $this.contentOptions | Add-Member -MemberType NoteProperty -Name 'sfCollectionMaps' -Value @() } } [void]_CopyModToSdk() { $xf = @("*.x2proj") if (![string]::IsNullOrEmpty($this.contentOptionsJsonFilename)) { $xf += $this.contentOptionsJsonFilename } Write-Host "Copying mod project to staging..." Robocopy.exe "$($this.modSrcRoot)" "$($this.stagingPath)" *.* $global:def_robocopy_args /XF @xf /XD "ContentForCook" Write-Host "Copied project to staging." if ($this._HasScriptPackages()) { New-Item "$($this.stagingPath)/Script" -ItemType Directory } # read mod metadata from the x2proj file Write-Host "Reading mod metadata from $($this.modX2ProjPath)" [xml]$x2projXml = Get-Content -Path "$($this.modX2ProjPath)" $xmlPropertyGroup = $x2projXml.Project.PropertyGroup $modProperties = if ($xmlPropertyGroup -is [array]) { $xmlPropertyGroup[0] } else { $xmlPropertyGroup } $publishedId = $modProperties.SteamPublishID if ($this.publishID -ne -1) { $publishedId = $this.publishID Write-Host "Using override workshop ID of $publishedId" } $title = $modProperties.Name $description = $modProperties.Description Write-Host "Read." Write-Host "Writing mod metadata..." Set-Content "$($this.xcomModPath)" "[mod]`npublishedFileId=$publishedId`nTitle=$title`nDescription=$description`nRequiresXPACK=true" Write-Host "Written." # Create CookedPCConsole folder for the mod if ($this.cookHL) { New-Item "$($this.stagingPath)/CookedPCConsole" -ItemType Directory } } [void]_CleanAdditional() { # clean foreach ($modName in $this.clean) { $cleanDir = "$($this.sdkPath)/XComGame/Mods/$($modName)" if (Test-Path $cleanDir) { Write-Host "Cleaning $($modName)..." Remove-Item -Recurse -Force $cleanDir } } } [void]_ConvertLocalization() { Get-ChildItem "$($this.stagingPath)\Localization" -Recurse -File | Foreach-Object { $content = Get-Content $_.FullName -Encoding UTF8 $content | Out-File $_.FullName -Encoding Unicode } } [void]_CopyToSrc() { # mirror the SDK's SrcOrig to its Src Write-Host "Mirroring SrcOrig to Src..." Robocopy.exe "$($this.sdkPath)\Development\SrcOrig" "$($this.devSrcRoot)" *.uc *.uci $global:def_robocopy_args Write-Host "Mirrored SrcOrig to Src." $this._ParseMacroFile("$($this.devSrcRoot)\Core\Globals.uci") # Copy dependencies Write-Host "Copying dependency sources to Src..." foreach ($depfolder in $this.include) { Get-ChildItem "$($depfolder)" -Directory -Name | Write-Host $this._CopySrcFolder($depfolder) } Write-Host "Copied dependency sources to Src." if ($this._HasScriptPackages()) { # copying the mod's scripts to the script staging location Write-Host "Copying the mod's sources to Src..." $this._CopySrcFolder("$($this.modSrcRoot)\Src") Write-Host "Copied mod sources to Src." } } [void]_CopySrcFolder([string] $includeDir) { Copy-Item "$($includeDir)\*" "$($this.devSrcRoot)\" -Force -Recurse -WarningAction SilentlyContinue $extraGlobalsFile = "$($includeDir)\extra_globals.uci" if (Test-Path $extraGlobalsFile) { # append extra_globals.uci to globals.uci "// Macros included from $($extraGlobalsFile)" | Add-Content "$($this.devSrcRoot)\Core\Globals.uci" Get-Content $extraGlobalsFile | Add-Content "$($this.devSrcRoot)\Core\Globals.uci" $this._ParseMacroFile($extraGlobalsFile) } } [void]_ParseMacroFile([string]$file) { $lines = Get-Content $file # check for dupes $redefine = $false $lineNr = 1 foreach ($line in $lines) { $defineMatch = $line | Select-String -Pattern '^\s*`define\s*([a-zA-Z][a-zA-Z0-9_]*)' if ($null -ne $defineMatch -and $defineMatch.Matches.Success) { [string]$macroName = $defineMatch.Matches.Groups[1] $prevDef = $this.macroDefs[$macroName] if ($null -ne $prevDef -and -not $redefine -and $prevDef.file -ne $file) { Write-Host -ForegroundColor Red "Error: Implicit redefinition of macro $($macroName)" $defineWord = if ($prevDef.redefine) { "redefined" } else { "defined" } Write-Host " Note: Previously $($defineWord) at $($prevDef.file)($($prevDef.lineNr))" Write-Host " Note: Implicitly redefined at $($file)($($lineNr))" Write-Host " Help: Rename the macro, or add ``// X2MBC-Redefine`` above to explicitly redefine and silence this warning." ThrowFailure "Implicit macro redefinition." } $macroDef = [PSCustomObject]@{ file = $file lineNr = $lineNr redefine = $redefine } $this.macroDefs[$macroName] = $macroDef } elseif ($line -match '^\s*`define') { ThrowFailure "Unrecognized macro at $($file)($($line)). This is a bug in X2ModBuildCommon." } $redefine = $line -match "X2MBC-Redefine" $lineNr += 1 } } [void]_RunPreMakeHooks() { foreach ($hook in $this.preMakeHooks) { $hook.Invoke() } } [string]_GetCoreMtime() { if (Test-Path "$($this.sdkPath)/XComGame/Script/Core.u") { return Get-Item "$($this.sdkPath)/XComGame/Script/Core.u" | Select-Object -ExpandProperty LastWriteTime } else { return "missing" } } [void]_CheckCleanCompiled() { # #16: Switching between debug and release causes an error in the make commandlet if script packages aren't deleted. # #20: Changes to Globals.uci aren't tracked by UCC, so we must delete script packages if Globals.uci changes. $lastBuildDetails = Get-Content $this.makeFingerprintsPath | ConvertFrom-Json $buildMode = if ($this.debug -eq $true) { "debug" } else { "release" } $globalsHash = Get-FileHash "$($this.sdkPath)\Development\Src\Core\Globals.uci" | Select-Object -ExpandProperty Hash $coreTimeStamp = $this._GetCoreMtime() $rebuild = if ($lastBuildDetails.buildMode -ne $buildMode) { Write-Host "Detected switch between debug and non-debug build." $true } elseif ($lastBuildDetails.coreTimestamp -ne $coreTimeStamp) { Write-Host "Detected previous external rebuild." $true } elseif ($lastBuildDetails.globalsHash -ne $globalsHash) { Write-Host "Detected change in macros (Globals.uci)." $true } else { $false } # Order: Deleting first cannot cause an issue because the compiler will just rebuild. if ($rebuild) { Write-Host "Cleaning all compiled scripts from $($this.sdkPath)/XComGame/Script to avoid compiler error..." Remove-Item "$($this.sdkPath)/XComGame/Script/*.u" Write-Host "Cleaned." } $lastBuildDetails.buildMode = $buildMode $lastBuildDetails.globalsHash = $globalsHash # Similarly, recording the previous invocation fingerprints before the build is complete # cannot cause an issue because the compiler will simply continue an interrupted build. $lastBuildDetails | ConvertTo-Json | Set-Content -Path $this.makeFingerprintsPath } [void]_RecordCoreTimestamp() { # Unfortunately, ModBuddy with Fxs' plugin can rebuild the packages under our nose. # As a last resort, record the Core.u timestamp $lastBuildDetails = Get-Content $this.makeFingerprintsPath | ConvertFrom-Json $lastBuildDetails.coreTimestamp = $this._GetCoreMtime() $lastBuildDetails | ConvertTo-Json | Set-Content -Path $this.makeFingerprintsPath } [void]_RunMakeBase() { # build the base game scripts $scriptsMakeArguments = "make -nopause -unattended" if ($this.final_release -eq $true) { $scriptsMakeArguments = "$scriptsMakeArguments -final_release" } if ($this.debug -eq $true) { $scriptsMakeArguments = "$scriptsMakeArguments -debug" } $handler = [MakeStdoutReceiver]::new($this) $handler.processDescr = "compiling base game scripts" $this._InvokeEditorCmdlet($handler, $scriptsMakeArguments, 50) # If we build in final release, we must build the normal scripts too if ($this.final_release -eq $true) { Write-Host "Compiling base game scripts without final_release..." $scriptsMakeArguments = "make -nopause -unattended" $handler = [MakeStdoutReceiver]::new($this) $handler.processDescr = "compiling base game scripts" $this._InvokeEditorCmdlet($handler, $scriptsMakeArguments, 50) } } [void]_RunMakeMod() { # build the mod's scripts $scriptsMakeArguments = "make -nopause -mods $($this.modNameCanonical) $($this.stagingPath)" if ($this.debug -eq $true) { $scriptsMakeArguments = "$scriptsMakeArguments -debug" } $handler = [MakeStdoutReceiver]::new($this) $handler.processDescr = "compiling mod scripts" $this._InvokeEditorCmdlet($handler, $scriptsMakeArguments, 50) } [bool]_HasNativePackages() { # Check if this is a Highlander and we need to cook things $anynative = $false foreach ($name in $this.modScriptPackages) { if ($global:nativescriptpackages.Contains($name)) { $anynative = $true break } } return $anynative } [bool] _HasScriptPackages () { return $this.modScriptPackages.Length -gt 0 } [bool] _ShouldCompileBase () { if ($this._HasScriptPackages()) { return $true } # We need to compile base game scripts if cooking assets, otherwise the cooker will just crash if the SDK was cleaned beforehand if ($this._AnyAssetsToCook()) { return $true } return $false } [void]_CopyScriptPackages() { # copy packages to staging foreach ($name in $this.modScriptPackages) { if ($this.cookHL -and $global:nativescriptpackages.Contains($name)) { # This is a native (cooked) script package -- copy important upks Copy-Item "$($this.cookerOutputPath)\$name.upk" "$($this.stagingPath)\CookedPCConsole" -Force -WarningAction SilentlyContinue Copy-Item "$($this.cookerOutputPath)\$name.upk.uncompressed_size" "$($this.stagingPath)\CookedPCConsole" -Force -WarningAction SilentlyContinue Write-Host "$($this.cookerOutputPath)\$name.upk" } else { # Or this is a non-native package Copy-Item "$($this.sdkPath)\XComGame\Script\$name.u" "$($this.stagingPath)\Script" -Force -WarningAction SilentlyContinue Write-Host "$($this.sdkPath)\XComGame\Script\$name.u" } } } [void]_PrecompileShaders() { Write-Host "Checking the need to PrecompileShaders" $contentfiles = @() # We don't need to consider # .umaps - they will never contain material (instances) objects - only reference them # ContentForCook - seekfree packages have an inlined shader cache if (Test-Path "$($this.modSrcRoot)/Content") { $contentfiles += Get-ChildItem "$($this.modSrcRoot)/Content" -Include *.upk -Recurse -File } if ($contentfiles.length -eq 0) { Write-Host "No content files, skipping PrecompileShaders." return } # for ($i = 0; $i -lt $contentfiles.Length; $i++) { # Write-Host $contentfiles[$i] # } $need_shader_precompile = $false $shaderCacheName = "$($this.modNameCanonical)_ModShaderCache.upk" $cachedShaderCachePath = "$($this.buildCachePath)/$($shaderCacheName)" # Try to find a reason to precompile the shaders if (!(Test-Path -Path $cachedShaderCachePath)) { $need_shader_precompile = $true } elseif ($contentfiles.length -gt 0) { $shader_cache = Get-Item $cachedShaderCachePath foreach ($file in $contentfiles) { if ($file.LastWriteTime -gt $shader_cache.LastWriteTime -Or $file.CreationTime -gt $shader_cache.LastWriteTime) { $need_shader_precompile = $true break } } } if ($need_shader_precompile) { # build the mod's shader cache Write-Host "Precompiling Shaders..." $precompileShadersFlags = "precompileshaders -nopause platform=pc_sm4 DLC=$($this.modNameCanonical)" $handler = [PassthroughReceiver]::new() $handler.processDescr = "precompiling shaders" $this._InvokeEditorCmdlet($handler, $precompileShadersFlags, 10) Write-Host "Generated Shader Cache." Copy-Item -Path "$($this.stagingPath)/Content/$shaderCacheName" -Destination $this.buildCachePath } else { Write-Host "No reason to precompile shaders, using existing" Copy-Item -Path $cachedShaderCachePath -Destination "$($this.stagingPath)/Content" } } [void]_RunCookAssets() { $step = [ModAssetsCookStep]::new($this) $step.Execute() } [void]_RunCookHL() { $this._EnsureCookerOutputDirExists() # Cook it # Normally, the mod tools create a symlink in the SDK directory to the game CookedPCConsole directory, # but we'll just be using the game one to make it more robust $cookedpcconsoledir = [io.path]::combine($this.gamePath, 'XComGame', 'CookedPCConsole') [System.String[]]$files = "GuidCache.upk", "GlobalPersistentCookerData.upk", "PersistentCookerShaderData.bin" foreach ($name in $files) { if(-not(Test-Path ([io.path]::combine($this.cookerOutputPath, $name)))) { Write-Host "Copying $name..." Copy-Item ([io.path]::combine($cookedpcconsoledir, $name)) $this.cookerOutputPath } } # Ideally, the cooking process wouldn't modify the big *.tfc files, but it does, so we don't overwrite existing ones (/XC /XN /XO) # In order to "reset" the cooking direcory, just delete it and let the script recreate them Write-Host "Copying Texture File Caches..." Robocopy.exe "$cookedpcconsoledir" "$($this.cookerOutputPath)" *.tfc /NJH /XC /XN /XO Write-Host "Copied Texture File Caches." # Prepare editor args $cook_args = @("cookpackages", "-platform=pcconsole", "-quickanddirty", "-modcook", "-sha", "-multilanguagecook=INT+FRA+ITA+DEU+RUS+POL+KOR+ESN", "-singlethread", "-nopause") if ($this.final_release -eq $true) { $cook_args += "-final_release" } # The CookPackages commandlet generally is super unhelpful. The output is basically always the same and errors # don't occur -- it rather just crashes the game. Hence, we just buffer the output and present it to user only # if something went wrong # TODO: Filter more lines for HL cook? `Hashing`? `SHA: package not found`? `Couldn't find localized resource`? # `Warning, Texture file cache waste exceeds`? `Warning, Package _ is not conformed`? $handler = [BufferingReceiver]::new() $handler.processDescr = "cooking native packages" # Cook it! Write-Host "Invoking CookPackages (this may take a while)" $this._InvokeEditorCmdlet($handler, $cook_args, 10) } [void] _EnsureCookerOutputDirExists () { if(-not(Test-Path $this.cookerOutputPath)) { Write-Host "Creating Published/CookedPCConsole directory..." New-Item $this.cookerOutputPath -ItemType Directory } } [void]_CopyMissingUncooked() { if ($this.contentOptions.missingUncooked.Length -lt 1) { Write-Host "Skipping Missing Uncooked logic" return } Write-Host "Including MissingUncooked" $missingUncookedPath = [io.path]::Combine($this.stagingPath, "Content", "MissingUncooked") $sdkContentPath = [io.path]::Combine($this.sdkPath, "XComGame", "Content") if (!(Test-Path $missingUncookedPath)) { New-Item -ItemType "directory" -Path $missingUncookedPath } foreach ($fileName in $this.contentOptions.missingUncooked) { (Get-ChildItem -Path $sdkContentPath -Filter $fileName -Recurse).FullName | Copy-Item -Destination $missingUncookedPath } } [void]_FinalCopy() { # copy all staged files to the actual game's mods folder # TODO: Is the string interpolation required in the robocopy calls? Robocopy.exe "$($this.stagingPath)" "$($this.finalModPath)" *.* $global:def_robocopy_args } [string[]] _PrepareBuildCacheEngineIniWithAdditions ([string] $fileNamePrefix, [array] $lines) { $localDefaultEngineIniPath = [io.path]::combine($this.buildCachePath, $fileNamePrefix + "_DefaultEngine.ini") $localXComEngineIniPath = [io.path]::combine($this.buildCachePath, $fileNamePrefix + "_XComEngine.ini") $newEngineIniContent = $this.sdkEngineIniContent + "`n" + ($lines -join "`n") + "`n" $newEngineIniContent | Set-Content $localDefaultEngineIniPath -NoNewline return @($localDefaultEngineIniPath, $localXComEngineIniPath) } [void]_InvokeEditorCmdlet([StdoutReceiver] $receiver, [string] $makeFlags, [int] $sleepMsDuration) { # Create a ProcessStartInfo object to hold the details of the make command, its arguments, and set up # stdout/stderr redirection. $pinfo = New-Object System.Diagnostics.ProcessStartInfo $pinfo.FileName = $this.commandletHostPath $pinfo.RedirectStandardOutput = $true $pinfo.RedirectStandardError = $true $pinfo.UseShellExecute = $false $pinfo.Arguments = $makeFlags $pinfo.WorkingDirectory = $this.commandletHostPath | Split-Path # Set the exited flag on our exit object on process exit. # We need another object for the Exited event to set a flag we can monitor from this function. $exitData = New-Object psobject -property @{ exited = $false } $exitAction = { $event.MessageData.exited = $true } # An action for handling data written to stderr. The Cmdlets don't seem to write anything here, # or at least not diagnostics, so we can just pass it through. $errAction = { $errTxt = $Event.SourceEventArgs.Data Write-Host $errTxt } $messageData = New-Object psobject -property @{ handler = $receiver } # Create an stdout filter action delegating to the actual implementation $outAction = { [StdoutReceiver] $handler = $event.MessageData.handler [string] $outTxt = $Event.SourceEventArgs.Data $handler.ParseLine($outTxt) } # Create the process and register for the various events we care about. $process = New-Object System.Diagnostics.Process Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action $outAction -MessageData $messageData | Out-Null Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action $errAction | Out-Null Register-ObjectEvent -InputObject $process -EventName Exited -Action $exitAction -MessageData $exitData | Out-Null $process.StartInfo = $pinfo # All systems go! $process.Start() | Out-Null $process.BeginOutputReadLine() $process.BeginErrorReadLine() # Wait for the process to exit. This is horrible, but using $process.WaitForExit() blocks # the powershell thread so we get no output from make echoed to the screen until the process finishes. # By polling we get regular output as it goes. try { if ($sleepMsDuration -lt 1) { while (!$exitData.exited) { # Just spin } } else { while (!$exitData.exited) { Start-Sleep -m $sleepMsDuration } } } finally { # If we are stopping MSBuild hosted build, we need to kill the editor manually if (!$exitData.exited) { Write-Host "Killing $($receiver.processDescr) tree" KillProcessTree $process.Id } } $exitCode = $process.ExitCode $receiver.Finish($exitCode) } [bool] _AnyAssetsToCook () { return ($this.contentOptions.sfStandalone.Length -gt 0) -or ($this.contentOptions.sfMaps.Length -gt 0) -or ($this.contentOptions.sfCollectionMaps.Length -gt 0) } } class ModAssetsCookStep { [BuildProject] $project [string] $xpackTfcSuffix = '_XPACK_' [string] $actualTfcSuffix [string] $contentForCookPath [string] $collectionMapsPath [string] $sdkContentModsDir [string] $sdkContentModsOurDir [string[]] $dirtyMaps [string[]] $cookedMaps [string[]] $sfCollectionOnlyMaps [string] $engineIniDefaultPath [string] $engineIniXComPath [string] $editorArgs [string] $cookerOutputTrackerPath [object] $cookerOutputTracker ModAssetsCookStep ([BuildProject] $project) { $this.project = $project } [void] Execute() { if (!$this.project._AnyAssetsToCook()) { Write-Host "No asset cooking is requested, skipping" return # TODO: Check if there are any assets in ContentForCook when no cooking is configured } Write-Host "Initializing assets cooking" $this._Init() $this._VerifyProjectAndSdk() Write-Host "Preparing assets cooking" $this._PrepareSdkFolders() $this._PrepareProjectCache() $this._VerifyCachedTfcsNotAltered() # Needs to be after _PrepareProjectCache (CollectionMaps are created) and _PrepareSdkFolders (otherwise Get-ChildItem for TFCs fails) $this._VerifyCachedSfPackagesNotAltered() $this._DetermineDirtyMaps() $this._PrepareEngineIni() $this._PrepareEditorArgs() Write-Host "Starting assets cooking" $this._ExecuteCore() $this._WarnTfcGrowth() $this._RecordCookerOutputTracker() $this._StageArtifacts() Write-Host "Assets cook completed" } [void] _Init() { $this.actualTfcSuffix = "_$($this.project.modNameCanonical)_DLCTFC$($this.xpackTfcSuffix)" $this.contentForCookPath = "$($this.project.modSrcRoot)\ContentForCook" $this.collectionMapsPath = [io.path]::combine($this.project.buildCachePath, 'CollectionMaps') $this.sdkContentModsDir = [io.path]::combine($this.project.sdkPath, 'XComGame', 'Content', 'Mods') $this.sdkContentModsOurDir = [io.path]::combine($this.sdkContentModsDir, $this.project.modNameCanonical) $this.cookedMaps = @($this.project.contentOptions.sfMaps) foreach ($mapDef in $this.project.contentOptions.sfCollectionMaps) { $this.cookedMaps += $mapDef.name } $this.sfCollectionOnlyMaps = @() foreach ($mapDef in $this.project.contentOptions.sfCollectionMaps) { if ($null -eq (Get-ChildItem -Path $this.contentForCookPath -Filter $mapDef.name -Recurse)) { $this.sfCollectionOnlyMaps += $mapDef.name } } $this.cookerOutputTrackerPath = [io.path]::combine($this.project.buildCachePath, 'AssetsCookerOutputTracker.json') if (Test-Path $this.cookerOutputTrackerPath) { $this.cookerOutputTracker = Get-Content $this.cookerOutputTrackerPath | ConvertFrom-Json } else { $this.cookerOutputTracker = [PSCustomObject]@{ tfcFiles = @() # Assume no TFCs if no info is stored. This will cause a full recook if any are found sfPackages = @() } } } [void] _VerifyProjectAndSdk() { # TODO: consider removing this requirement. # It might be legitimate use case to cook a "secondary" vanilla package or a collection map that consists of only vanilla packages if (-not(Test-Path $this.contentForCookPath)) { ThrowFailure "Asset cooking is requested, but no ContentForCook folder is present" } if (Test-Path $this.sdkContentModsOurDir) { # If we have any files, then something is happening here - abort if ($null -ne (Get-ChildItem -Path $this.sdkContentModsOurDir -Force -Recurse)) { ThrowFailure "$($this.sdkContentModsOurDir) is already in use (not empty)" } } # The DLC cooker needs to read/copy the shipped GPCD $shippedGpcdPath = [io.path]::combine($this.project.sdkPath, 'XComGame', 'CookedPCConsole', 'GlobalPersistentCookerData.upk') if (!(Test-Path $shippedGpcdPath)) { ThrowFailure "$shippedGpcdPath does not exist. Please verify your that your SDK is configured correctly" } } [void] _PrepareProjectCache() { # Prep the folder for the collection maps # Not the most efficient approach, but there are bigger time saves to be had Remove-Item $this.collectionMapsPath -Force -Recurse -WarningAction SilentlyContinue -ErrorAction SilentlyContinue New-Item -ItemType "directory" -Path $this.collectionMapsPath foreach ($map in $this.sfCollectionOnlyMaps) { # Important: we cannot use .umap extension here - git lfs (if in use) gets confused during git subtree add # See https://github.com/X2CommunityCore/X2ModBuildCommon/wiki/Do-not-use-.umap-for-files-in-this-repo Copy-Item "$global:buildCommonSelfPath\EmptyUMap" "$($this.collectionMapsPath)\$map.umap" } } [void] _VerifyCachedTfcsNotAltered () { if (!$this._CheckCachedTfcsNotAltered()) { Write-Host "Performing a full recook" CleanModAssetCookerOutput $this.project.sdkPath $this.project.modNameCanonical @($this.contentForCookPath, $this.collectionMapsPath) # Save that everything is deleted $this.cookerOutputTracker.tfcFiles = @() $this._RecordCookerOutputTracker() } } [bool] _CheckCachedTfcsNotAltered () { [System.Collections.ArrayList] $currentTfcs = @($this._GetOurTfcFiles() | Select-Object -ExpandProperty Name) foreach ($trackedFileData in $this.cookerOutputTracker.tfcFiles) { if (!$currentTfcs.Contains($trackedFileData.fullFileName)) { Write-Host "$($trackedFileData.fullFileName) is missing" return $false } $path = [io.path]::combine($this.project.cookerOutputPath, $trackedFileData.fullFileName) $file = Get-Item $path if ($file.LastWriteTimeUtc.Ticks -ne $trackedFileData.lastUpdatedUtc) { Write-Host "$($trackedFileData.fullFileName) timestamp mismatch" return $false } $currentTfcs.Remove($trackedFileData.fullFileName) } if ($currentTfcs.Count -gt 0) { Write-Host "Unexpected TFCs found: $currentTfcs" return $false } return $true } # Why is this needed? # It's technically fine to cook the same package from multiple mods. # However, if the package ends up having any textures that point to a TFC file, # they will be pointing to a TFC that we will not ship (as it is of a different DLC) # which at runtime will at best cause missing mip levels, and at worst, crash the game. [void] _VerifyCachedSfPackagesNotAltered () { # Delete tracked if timestamp doesn't match foreach ($trackedFileData in $this.cookerOutputTracker.sfPackages) { $path = [io.path]::combine($this.project.cookerOutputPath, $trackedFileData.fullFileName) if (Test-Path $path) { $file = Get-Item $path if ($file.LastWriteTimeUtc.Ticks -ne $trackedFileData.lastUpdatedUtc) { Write-Host "$($trackedFileData.fullFileName) timestamp mismatch - deleting" Remove-Item $path -Force } } } # Delete supposed-to-cook if they exist but are not tracked foreach ($fileName in $this._GetDesiredOutputPackageFileNames()) { $path = [io.path]::combine($this.project.cookerOutputPath, $fileName) $trackedFileData = $this._GetSfPackageTrackerData($fileName) if ($null -eq $trackedFileData -and (Test-Path $path)) { Write-Host "$fileName exists, but is not tracked - deleting" Remove-Item $path -Force } } } [void] _DetermineDirtyMaps () { $this.dirtyMaps = @() # Check the dev-made maps # Not the best (doesn't take into account the dependencies - FIXME) but will suffice for now. foreach ($map in $this.cookedMaps) { if ($this.sfCollectionOnlyMaps.Contains($map)) { continue; } $cookedPath = [io.path]::combine($this.project.cookerOutputPath, "$map.upk") if (!(Test-Path $cookedPath)) { Write-Host "$map has no cooked version" $this.dirtyMaps += $map } else { $original = Get-ChildItem -Path $this.contentForCookPath -Include "$map.umap" -Recurse if ($original.LastWriteTime -gt (Get-Item $cookedPath).LastWriteTime) { $this.dirtyMaps += $map Write-Host "$map original was updated" } } } # Check the collection maps foreach ($mapDef in $this.project.contentOptions.sfCollectionMaps) { $map = $mapDef.name $cookedPath = [io.path]::combine($this.project.cookerOutputPath, "$map.upk") if ($this.dirtyMaps.Contains($map)) { continue; } if (!(Test-Path $cookedPath)) { Write-Host "$map has no cooked version" $this.dirtyMaps += $map } else { $existingCooked = Get-Item $cookedPath foreach ($package in $mapDef.packages) { if (((Get-ChildItem -Path $this.contentForCookPath -Include "$package.upk" -Recurse).LastWriteTime) -gt $existingCooked.LastWriteTime) { Write-Host "$map dependency was updated ($package)" $this.dirtyMaps += $map break } } } } } [void] _PrepareEngineIni() { $this.engineIniDefaultPath, $this.engineIniXComPath = $this.project._PrepareBuildCacheEngineIniWithAdditions("AssetsCook", $this._PrepareEngineIniAdditions()) } [string[]] _PrepareEngineIniAdditions () { $lines = @() # "Inject" our assets into the SDK to make them visible to the cooker $lines += "[Core.System]" $lines += "+Paths=$($this.contentForCookPath)" $lines += "-Paths=..\..\XComGame\Content\Mods" # Do not actually load the packages from there if ($this.sfCollectionOnlyMaps.Length -gt 0) { $lines += "+Paths=$($this.collectionMapsPath)" } # Stop all the "Adding [...]" garbage # TODO: our maps here? $lines += "[Engine.X2DirectoriesToSkipEnumeration]" $lines += ".Directory=..\..\XComGame" $lines += ".Directory=..\..\Engine" # Collection maps # TODO: Switch + to . $lines += "[Engine.PackagesToForceCookPerMap]" foreach ($mapDef in $this.project.contentOptions.sfCollectionMaps) { $lines += "+Map=$($mapDef.name)" foreach ($package in $mapDef.packages) { $lines += "+Package=$package" } } return $lines } [void] _PrepareSdkFolders () { $this.project._EnsureCookerOutputDirExists() if (-not(Test-Path $this.sdkContentModsOurDir)) { Write-Host "Creating $($this.sdkContentModsOurDir) directory..." New-Item $this.sdkContentModsOurDir -ItemType Directory } } [void] _PrepareEditorArgs () { $cookerFlags = "-platform=pcconsole -skipmaps -TFCSUFFIX=$($this.xpackTfcSuffix) -singlethread -unattended -DLCName=$($this.project.modNameCanonical)" $mapsString = $this.dirtyMaps -join " " $this.editorArgs = "CookPackages $mapsString $cookerFlags -DEFENGINEINI=""$($this.engineIniDefaultPath)"" -ENGINEINI=""$($this.engineIniXComPath)""" } [void] _ExecuteCore () { # This try block needs to be kept as small as possible as it puts the SDK into a (temporary) invalid state try { if ($this.project.contentOptions.sfStandalone.Length -gt 0) { # Create iterator guard (the first package alphabetically is always skipped) $this._CreateMarkerPackageFile('000000000_________IteratorGuard') # Create dummy files for each of the seekfree standalone packages foreach ($package in $this.project.contentOptions.sfStandalone) { $this._CreateMarkerPackageFile($package) } } $this._InvokeAssetCooker($this.editorArgs) } finally { Write-Host "Cleaning up the asset cooking hacks" $cleanupFailed = $false try { Remove-Item -Recurse -Force "$($this.sdkContentModsOurDir)\*" Write-Host "Emptied $($this.sdkContentModsOurDir)" } catch { FailureMessage "Failed to empty $($($this.sdkContentModsOurDir))" FailureMessage $_ $cleanupFailed = $true } if ($cleanupFailed) { Write-Host "" Write-Host "" ThrowFailure "Failed to clean up the asset cooking hacks - your SDK is now in a corrupted state. Please preform the cleanup manually before building a mod or opening the editor." } } } [void] _CreateMarkerPackageFile ([string] $packageName) { New-Item -Path $this.sdkContentModsOurDir -Name "$packageName.upk" -ItemType File } [void] _InvokeAssetCooker ([string] $editorArguments) { Write-Host $editorArguments $handler = [ModcookReceiver]::new() $handler.processDescr = "cooking mod packages" # Even a sleep of 1 ms causes a noticable delay between cooker being done (files created) # and output completing. So, just spin $this.project._InvokeEditorCmdlet($handler, $editorArguments, 0) } [void] _WarnTfcGrowth () { $tfcs = $this._GetOurTfcFiles() $growthEntries = @() foreach ($file in $tfcs) { $trackedFileData = $this._GetTfcTrackerData($file.Name) if ($null -eq $trackedFileData) { # New file - ignore continue } if ($file.Length -eq $trackedFileData.originalSize) { continue } $increase = $file.Length / $trackedFileData.originalSize $growthEntries += [PSCustomObject]@{ Name = $file.Name OriginalSize = FormatFileSize($trackedFileData.originalSize) CurrentSize = FormatFileSize($file.Length) Increase = "${increase}x" } } if ($growthEntries.Length -gt 0) { $growthEntries | Format-Table | Out-String | Write-Host Write-Host "WARNING: TFC files grew since initial creation. This could indicate data duplication." Write-Host "Your mod will still function normally, but the file size might be larger than needed" Write-Host "(i.e. useless data present). See above for details." Write-Host "You should consider doing a full rebuild before distributing your mod (e.g. via the workshop)." Write-Host "" } # TODO: current logic doesn't account for case when a package (which has/had textures) is removed from the seekfree list. # We will keep shipping the TFC (with useless data) in this case without any warnings } [void] _RecordCookerOutputTracker () { # TFCs $tfcs = $this._GetOurTfcFiles() foreach ($file in $tfcs) { $trackedFileData = $this._GetTfcTrackerData($file.Name) if ($null -eq $trackedFileData) { # Write-Host "New file: $($file.Name)" $this.cookerOutputTracker.tfcFiles += [PSCustomObject]@{ fullFileName = $file.Name originalSize = $file.Length lastUpdatedUtc = $file.LastWriteTimeUtc.Ticks } continue } # Not a new file - just store the new last updated time $trackedFileData.lastUpdatedUtc = $file.LastWriteTimeUtc.Ticks } # SF packages $sfPackageFilesNames = $this._GetDesiredOutputPackageFileNames() # Write-Host "sfPackages: $sfPackageFilesNames" # SF packages (removed) $this.cookerOutputTracker.sfPackages = @($this.cookerOutputTracker.sfPackages | Where-Object { $sfPackageFilesNames.Contains($_.fullFileName) }) # SF packages (new/updated) foreach ($fileName in $sfPackageFilesNames) { $file = Get-Item "$($this.project.cookerOutputPath)\$fileName" $trackedFileData = $this._GetSfPackageTrackerData($file.Name) if ($null -eq $trackedFileData) { # Write-Host "New file: $($file.Name)" $this.cookerOutputTracker.sfPackages += [PSCustomObject]@{ fullFileName = $file.Name lastUpdatedUtc = $file.LastWriteTimeUtc.Ticks } continue } # Not a new file - just store the new last updated time $trackedFileData.lastUpdatedUtc = $file.LastWriteTimeUtc.Ticks } # Write the file $this.cookerOutputTracker | ConvertTo-Json | Set-Content -Path $this.cookerOutputTrackerPath } [string[]] _GetDesiredOutputPackageFileNames () { $sfPackageFilesNames = @($this.project.contentOptions.sfStandalone | Foreach-Object { "${_}_SF" }) $sfPackageFilesNames += $this.cookedMaps return @($sfPackageFilesNames | Foreach-Object { "$_.upk" }) } [PSCustomObject] _GetTfcTrackerData ([string] $fullFileName) { foreach ($fileData in $this.cookerOutputTracker.tfcFiles) { if ($fullFileName -eq $fileData.fullFileName) { return $fileData } } return $null } [PSCustomObject] _GetSfPackageTrackerData ([string] $fullFileName) { foreach ($fileData in $this.cookerOutputTracker.sfPackages) { if ($fullFileName -eq $fileData.fullFileName) { return $fileData } } return $null } [void] _StageArtifacts () { # Prepare the folder for cooked stuff $stagingCookedDir = [io.path]::combine($this.project.stagingPath, 'CookedPCConsole') if (!(Test-Path $stagingCookedDir)) { New-Item -ItemType "directory" -Path $stagingCookedDir } # Copy over the TFC files $this._GetOurTfcFiles() | Copy-Item -Destination $stagingCookedDir # Copy over the maps for ($i = 0; $i -lt $this.cookedMaps.Length; $i++) { $umap = $this.cookedMaps[$i]; Copy-Item "$($this.project.cookerOutputPath)\$umap.upk" -Destination $stagingCookedDir } # Copy over the SF packages for ($i = 0; $i -lt $this.project.contentOptions.sfStandalone.Length; $i++) { $package = $this.project.contentOptions.sfStandalone[$i]; $dest = [io.path]::Combine($stagingCookedDir, "${package}.upk"); # We need to remove the _SF suffix, otherwise the game won't find the package Copy-Item "$($this.project.cookerOutputPath)\${package}_SF.upk" -Destination $dest } } [System.IO.FileInfo[]] _GetOurTfcFiles () { return @(Get-ChildItem -Path $this.project.cookerOutputPath -Filter "*$($this.actualTfcSuffix).tfc") } } class StdoutReceiver { [bool] $crashDetected = $false [string] $processDescr = "" [void]ParseLine([string] $outTxt) { if ($outTxt.Contains("Crash Detected") -or $outTxt.Contains("(filename not found)")) { $this.crashDetected = $true } } [void]Finish([int] $exitCode) { if ($this.crashDetected) { ThrowFailure "Crash detected while $($this.processDescr)" } if ($exitCode -ne 0) { ThrowFailure "Failed $($this.processDescr)" } } } class PassthroughReceiver : StdoutReceiver { PassthroughReceiver(){ } [void]ParseLine([string] $outTxt) { ([StdoutReceiver]$this).ParseLine($outTxt) Write-Host $outTxt } [void]Finish([int] $exitCode) { ([StdoutReceiver]$this).Finish($exitCode) } } class BufferingReceiver : StdoutReceiver { [object] $logLines BufferingReceiver(){ $this.logLines = New-Object System.Collections.Generic.List[System.Object] } [void]ParseLine([string] $outTxt) { ([StdoutReceiver]$this).ParseLine($outTxt) $this.logLines.Add($outTxt) } [void]Finish([int] $exitCode) { if (($exitCode -ne 0) -or $this.crashDetected) { foreach ($line in $this.logLines) { Write-Host $line } } ([StdoutReceiver]$this).Finish($exitCode) } } class MakeStdoutReceiver : StdoutReceiver { [BuildProject] $proj [string[]] $reversePaths MakeStdoutReceiver( [BuildProject]$proj ){ $this.proj = $proj # Since later paths overwrite earlier files, check paths in reverse order $this.reversePaths = @("$($this.proj.sdkPath)\Development\SrcOrig") + $this.proj.include + @("$($this.proj.modSrcRoot)\Src") [array]::Reverse($this.reversePaths) } [void]ParseLine([string] $outTxt) { ([StdoutReceiver]$this).ParseLine($outTxt) $messagePattern = "^(.*)\(([0-9]*)\) : (.*)$" if (($outTxt -Match "Error|Warning") -And ($outTxt -Match $messagePattern)) { # extract original path from $matches automatic variable created by above -Match $origPath = $matches[1] # create regex pattern specifically from the part we're interested in replacing $pattern = [regex]::Escape("$($this.proj.sdkPath)\Development\Src") $found = $false foreach ($checkPath in $this.reversePaths) { $testPath = $origPath -Replace $pattern,$checkPath # if the file exists, it's certainly the one that caused the error if (Test-Path $testPath) { # Normalize path to get rid of `..`s $testPath = [IO.Path]::GetFullPath($testPath) # this syntax works with both VS Code and ModBuddy $outTxt = $outTxt -Replace $messagePattern, ($testPath + '($2) : $3') $found = $true break } } if (-not $found) { $outTxt = $outTxt -Replace $messagePattern, ($origPath + '($2) : $3') } } $summPattern = "^(Success|Failure) - ([0-9]+) error\(s\), ([0-9]+) warning\(s\) \(([0-9]+) Unique Errors, ([0-9]+) Unique Warnings\)" if (-Not ($outTxt -Match "Warning/Error Summary") -And $outTxt -Match "Warning|Error") { if ($outTxt -Match $summPattern) { $numErr = $outTxt -Replace $summPattern, '$2' $numWarn = $outTxt -Replace $summPattern, '$3' if (([int]$numErr) -gt 0) { $clr = "Red" } elseif (([int]$numWarn) -gt 0) { $clr = "Yellow" } else { $clr = "Green" } } else { if ($outTxt -Match "Error") { $clr = "Red" } else { $clr = "Yellow" } } Write-Host $outTxt -ForegroundColor $clr } else { Write-Host $outTxt } } [void]Finish([int] $exitCode) { ([StdoutReceiver]$this).Finish($exitCode) } } class ModcookReceiver : StdoutReceiver { [bool] $lastLineWasAdding = $false ModcookReceiver(){ } [void]ParseLine([string] $outTxt) { ([StdoutReceiver]$this).ParseLine($outTxt) $permitLine = $true # Default to true in case there is something we don't handle if ($outTxt.StartsWith("GFx movie package")) { $permitLine = $false if (!$this.lastLineWasAdding) { Write-Host "[GFx movie packages ...]" } $this.lastLineWasAdding = $true } else { $this.lastLineWasAdding = $false $permitLine = $true } if ($permitLine) { Write-Host $outTxt } } [void]Finish([int] $exitCode) { ([StdoutReceiver]$this).Finish($exitCode) } } function FailureMessage($message) { [System.Media.SystemSounds]::Hand.Play() Write-Host $message -ForegroundColor "Red" } function ThrowFailure($message) { throw $message } function SuccessMessage($message, $modNameCanonical) { [System.Media.SystemSounds]::Asterisk.Play() Write-Host $message -ForegroundColor "Green" Write-Host "$modNameCanonical ready to run." -ForegroundColor "Green" } function FormatElapsed($elapsed) { return $elapsed.TotalSeconds.ToString("0.00s", $global:invarCulture) } # https://stackoverflow.com/a/55942155/2588539 # $process.Kill() works but we really need to kill the child as well, since it's the one which is actually doing work # Unfotunately, $process.Kill($true) does nothing function KillProcessTree ([int] $ppid) { Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq $ppid } | ForEach-Object { KillProcessTree $_.ProcessId } Stop-Process -Id $ppid } # https://superuser.com/a/468795/673577 Function FormatFileSize () { Param ([int64]$size) If ($size -gt 1TB) {[string]::Format("{0:0.00} TB", $size / 1TB)} ElseIf ($size -gt 1GB) {[string]::Format("{0:0.00} GB", $size / 1GB)} ElseIf ($size -gt 1MB) {[string]::Format("{0:0.00} MB", $size / 1MB)} ElseIf ($size -gt 1KB) {[string]::Format("{0:0.00} kB", $size / 1KB)} ElseIf ($size -gt 0) {[string]::Format("{0:0.00} B", $size)} Else {""} }