XCom 2 bridge mod - https://steamcommunity.com/sharedfiles/filedetails/?id=2829105873
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                   {""}
}