+ 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 {""}
+ }