use X2ModBuildCommon framework

rohan
Jul 31, 2022, 9:15 AM
5NS5ONG275NSHBMAPNYNVQZRHMT3D5YPBHMSJYRJT5VLXXGREEFQC

Dependencies

Change contents

  • file addition: .scripts (d--r------)
    [2.1]
  • file addition: build.ps1 (----------)
    [0.20]
    Param(
    [string] $srcDirectory, # the path that contains your mod's .XCOM_sln
    [string] $sdkPath, # the path to your SDK installation ending in "XCOM 2 War of the Chosen SDK"
    [string] $gamePath, # the path to your XCOM 2 installation ending in "XCOM2-WaroftheChosen"
    [string] $config # build configuration
    )
    $ScriptDirectory = Split-Path $MyInvocation.MyCommand.Path
    $common = Join-Path -Path $ScriptDirectory "X2ModBuildCommon\build_common.ps1"
    Write-Host "Sourcing $common"
    . ($common)
    $builder = [BuildProject]::new("TCObridgeSMG", $srcDirectory, $sdkPath, $gamePath)
    # Use GIT to add Highlander submodule.
    # git submodule add https://github.com/X2CommunityCore/X2WOTCCommunityHighlander.git
    # Uncomment the next line to enable building against Highlander.
    # $builder.IncludeSrc("$srcDirectory\X2WOTCCommunityHighlander\X2WOTCCommunityHighlander\Src")
    switch ($config)
    {
    "debug" {
    $builder.EnableDebug()
    }
    "default" {
    # Nothing special
    }
    "" { ThrowFailure "Missing build configuration" }
    default { ThrowFailure "Unknown build configuration $config" }
    }
    $builder.SetWorkshopID(2829105873)
    # Uncomment this line to enable cooking.
    # $builder.SetContentOptionsJsonFilename("ContentOptions.json")
    $builder.InvokeBuild()
  • file addition: X2ModBuildCommon (d--r------)
    [0.20]
  • file addition: clean_cooker_output.ps1 (----------)
    [0.1366]
    function CleanModAssetCookerOutput (
    [string] $sdkPath, # the path to your SDK installation ending in "XCOM 2 War of the Chosen SDK"
    [string] $modNameCanonical,
    [string[]] $sourceAssetsPaths # Intended for ContentForCook and CollectionMaps, but any folder that has .upk or .umap files will work
    ) {
    # TODO: duplicates the logic in build_common.ps1
    $actualTfcSuffix = "_$($modNameCanonical)_DLCTFC_XPACK_"
    $cookerOutputPath = [io.path]::combine($sdkPath, 'XComGame', 'Published', 'CookedPCConsole')
    if (!(Test-Path $cookerOutputPath)) {
    Write-Host "No Published\CookedPCConsole directory - nothing to clean"
    return
    }
    $modMaps = @()
    $modPackages = @()
    foreach ($assetPath in $sourceAssetsPaths) {
    Write-Host "Asset path: $assetPath"
    if (!(Test-Path $assetPath)) { continue }
    $pathMaps = @(Get-ChildItem -Path $assetPath -Filter '*.umap' -Recurse -Force | Select-Object -ExpandProperty BaseName)
    $pathPackages = @(Get-ChildItem -Path $assetPath -Filter '*.upk' -Recurse -Force | Select-Object -ExpandProperty BaseName)
    Write-Host "Path maps: $pathMaps"
    Write-Host "Path packages: $pathPackages"
    $modMaps += $pathMaps
    $modPackages += $pathPackages
    }
    Write-Host "Removing SeekFree maps: $modMaps"
    $modMaps | ForEach-Object { Remove-Item -Force -LiteralPath "$cookerOutputPath\$_.upk" -WarningAction SilentlyContinue -ErrorAction SilentlyContinue }
    Write-Host "Removing SeekFree standalone packages: $modPackages"
    $modPackages | ForEach-Object { Remove-Item -Force -LiteralPath "$cookerOutputPath\$($_)_SF.upk" -WarningAction SilentlyContinue -ErrorAction SilentlyContinue }
    Write-Host "Removing TFCs: $actualTfcSuffix"
    Remove-Item -Force "$cookerOutputPath\*$actualTfcSuffix.tfc" -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
    Write-Host "Removing GuidCache"
    Remove-Item -Force "$cookerOutputPath\GuidCache_$modNameCanonical.upk" -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
    }
    function TryCleanHlCookerOutput (
    [string] $sdkPath, # the path to your SDK installation ending in "XCOM 2 War of the Chosen SDK"
    [string] $modSrcPath # the path to [mod root]/[mod project]/Src
    ) {
    $nativeScriptPackagesNames = @("XComGame", "Core", "Engine", "GFxUI", "AkAudio", "GameFramework", "UnrealEd", "GFxUIEditor", "IpDrv", "OnlineSubsystemPC", "OnlineSubsystemLive", "OnlineSubsystemSteamworks", "OnlineSubsystemPSN")
    $cookerOutputPath = [io.path]::combine($sdkPath, 'XComGame', 'Published', 'CookedPCConsole')
    if (!(Test-Path $cookerOutputPath)) {
    Write-Host "No Published\CookedPCConsole directory - nothing to clean"
    return
    }
    #################################
    ### Check if this is a HL mod ###
    #################################
    if (!(Test-Path $modSrcPath)) {
    Write-Host "No Src directory in mod - this is not a HL mod"
    return
    }
    $modPackages = Get-ChildItem $modSrcPath -Directory | Select-Object -ExpandProperty "Name"
    $anyNative = $false
    foreach ($name in $modPackages)
    {
    if ($nativeScriptPackagesNames.Contains($name)) {
    $anyNative = $true
    break
    }
    }
    if (!$anyNative) {
    Write-Host $modPackages
    Write-Host "Not a highlander mod - skipping cleaning HL cooker output"
    return
    }
    #########################################
    ### Prepare a list of files to delete ###
    #########################################
    $filesToRemove = @()
    # Native script packages
    foreach ($package in $nativeScriptPackagesNames) {
    $filesToRemove += "$package.upk"
    $filesToRemove += "$package.upk.uncompressed_size"
    }
    # Startup (unlocalized)
    $filesToRemove += "Startup.upk"
    $filesToRemove += "Startup.upk.uncompressed_size"
    # Startup (localized)
    $gameLangs = @("INT", "FRA", "ITA", "DEU", "RUS", "POL", "KOR", "ESN")
    foreach ($lang in $gameLangs) {
    $filesToRemove += "Startup_LOC_$lang.upk"
    $filesToRemove += "Startup_LOC_$lang.upk.uncompressed_size"
    }
    # TFC files
    $tfcGroups = @("CharTextures", "Lighting", "Textures", "World")
    foreach ($tfcGroup in $tfcGroups) {
    # Unsuffixed
    $filesToRemove += "$tfcGroup.tfc"
    $filesToRemove += "$tfcGroup-a.tfc"
    # Suffixed
    $filesToRemove += "${tfcGroup}_XPACK_.tfc"
    $filesToRemove += "$tfcGroup-a_XPACK_.tfc"
    # TLE
    $filesToRemove += "${tfcGroup}_TLE_DLCTFC_XPACK_.tfc"
    $filesToRemove += "$tfcGroup-a_TLE_DLCTFC_XPACK_.tfc"
    }
    # Singletons
    $filesToRemove += "GuidCache.upk"
    $filesToRemove += "GlobalPersistentCookerData.upk"
    $filesToRemove += "PersistentCookerShaderData.bin"
    ######################
    ### Actual removal ###
    ######################
    Write-Host "Removing HL cooker output files from $cookerOutputPath"
    foreach ($file in $filesToRemove) {
    Write-Host " .\$file"
    Remove-Item -Force "$cookerOutputPath\$file" -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
    }
    }
  • file addition: clean.ps1 (----------)
    [0.1366]
    Param(
    [string] $modName, # mod folder name
    [string] $srcDirectory, # the path that contains your mod's .XCOM_sln
    [string] $sdkPath, # the path to your SDK installation ending in "XCOM 2 War of the Chosen SDK"
    [string] $gamePath # the path to your XCOM 2 installation ending in "XCOM2-WaroftheChosen"
    )
    $ErrorActionPreference = "Stop"
    Set-StrictMode -Version 3.0
    $scriptDirectory = Split-Path $MyInvocation.MyCommand.Path
    $cleanCookerOutput = Join-Path -Path $scriptDirectory "clean_cooker_output.ps1"
    Write-Host "Sourcing $cleanCookerOutput"
    . $cleanCookerOutput
    if ($null -eq $modName -or $modName -eq "") {
    throw "`$modName empty???"
    }
    Write-Host "Deleting all cached build artifacts..."
    # This needs to be before $srcDirectory\BuildCache is deleted - otherwise we will miss our collection maps
    # This is dumb, yes, but to fix this we need to rework how BuildProject is used from build.ps1 (and here)
    CleanModAssetCookerOutput $sdkPath $modName @("$srcDirectory\$modName\ContentForCook", "$srcDirectory\BuildCache\CollectionMaps")
    $files = @(
    "$sdkPath\XComGame\lastBuildDetails.json",
    "$sdkPath\XComGame\Script\*.u",
    "$sdkPath\XComGame\ScriptFinalRelease\*.u",
    "$sdkPath\XComGame\Content\LocalShaderCache-PC-D3D-SM4.upk"
    )
    $folders = @(
    "$srcDirectory\BuildCache",
    "$sdkPath\Development\Src\*",
    "$sdkPath\XComGame\Mods\*",
    "$gamePath\XComGame\Mods\$modName"
    )
    $files | ForEach-Object {
    Write-Host "Removing file(s) $($_)"
    Remove-Item -Force $_ -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
    }
    $folders | ForEach-Object {
    Write-Host "Removing folders(s) $($_)"
    Remove-Item -Recurse -Force $_ -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
    }
    TryCleanHlCookerOutput $sdkPath "$srcDirectory\$modName\Src"
    Write-Host "Cleaned."
  • file addition: build_common.ps1 (----------)
    [0.1366]
    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 {""}
    }
  • file addition: XCOM2.targets (----------)
    [0.1366]
    <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <!-- BEGIN Required for ModBuddy -->
    <PropertyGroup>
    <StartAction>Program</StartAction>
    <StartProgram>$(XCOM2_GamePath)\..\Binaries\Win64\Launcher\StartDebugging.bat</StartProgram>
    </PropertyGroup>
    <PropertyGroup>
    <OutputPath>.</OutputPath>
    </PropertyGroup>
    <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Default|XCOM 2' ">
    <OutputPath>.</OutputPath>
    </PropertyGroup>
    <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
    <OutputPath>.</OutputPath>
    </PropertyGroup>
    <PropertyGroup>
    <ProjectDir Condition="'$(BuildPath)'==''">$(MSBuildProjectDirectory)</ProjectDir>
    <ModsDir>$(XCOM2_UserPath)\Mods\</ModsDir>
    <OutputDir>$(ModsDir)$(SafeName)\</OutputDir>
    </PropertyGroup>
    <!-- Prevent spontaneous build warnings/errors -->
    <Target Name="GetFrameworkPaths" />
    <!-- END Required for ModBuddy -->
    <!-- Setup default build entrypoint if none is set -->
    <PropertyGroup>
    <BuildEntryFileName Condition=" '$(BuildEntryFileName)' == '' ">build.ps1</BuildEntryFileName>
    <BuildEntryPs1 Condition=" '$(BuildEntryPs1)' == '' ">$(ScriptsDir)$(BuildEntryFileName)</BuildEntryPs1>
    </PropertyGroup>
    <!-- Setup powershell build config -->
    <PropertyGroup Condition=" '$(BuildEntryConfig)' == '' ">
    <BuildEntryConfig Condition=" '$(Configuration)' == 'Default' ">default</BuildEntryConfig>
    <BuildEntryConfig Condition=" '$(Configuration)' == 'Debug' ">debug</BuildEntryConfig>
    </PropertyGroup>
    <!-- Misc targets -->
    <Target Name="Clean">
    <ItemGroup>
    <Args Include="modName">
    <Value>$(SafeName)</Value>
    </Args>
    </ItemGroup>
    <InvokePowershellTask
    EntryPs1="$([System.IO.Path]::GetFullPath('$(BuildCommonRoot)clean.ps1'))"
    SolutionRoot="$([System.IO.Path]::GetFullPath('$(SolutionRoot)'))"
    SdkInstallPath="$([System.IO.Path]::GetFullPath('$(XCOM2_UserPath)..\\'))"
    GameInstallPath="$([System.IO.Path]::GetFullPath('$(XCOM2_GamePath)..\\'))"
    AdditionalArgs="@(Args)"
    />
    </Target>
    <Target Name="ReBuild">
    <CallTarget Targets="Clean;Default"/>
    </Target>
    <!-- The actual build -->
    <Target Name="Default">
    <!-- Uncomment if MSBuild insists that project is up to date -->
    <!-- <Exec Command="exit" /> -->
    <ItemGroup>
    <Args Include="config">
    <Value>$(BuildEntryConfig)</Value>
    </Args>
    </ItemGroup>
    <!-- Clean up the \..\ from the paths - makes it easier to debug -->
    <InvokePowershellTask
    EntryPs1="$([System.IO.Path]::GetFullPath('$(BuildEntryPs1)'))"
    SolutionRoot="$([System.IO.Path]::GetFullPath('$(SolutionRoot)'))"
    SdkInstallPath="$([System.IO.Path]::GetFullPath('$(XCOM2_UserPath)..\\'))"
    GameInstallPath="$([System.IO.Path]::GetFullPath('$(XCOM2_GamePath)..\\'))"
    AdditionalArgs="@(Args)"
    />
    </Target>
    <!-- Task definition -->
    <UsingTask TaskName="InvokePowershellTask" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll">
    <ParameterGroup>
    <EntryPs1 Required="true" />
    <SolutionRoot Required="true" />
    <SdkInstallPath Required="true" />
    <GameInstallPath Required="true" />
    <AdditionalArgs ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="false" />
    </ParameterGroup>
    <Task>
    <Reference Include="System.Management.Automation" />
    <Code Language="cs" Source="$(BuildCommonRoot)InvokePowershellTask.cs" />
    </Task>
    </UsingTask>
    </Project>
  • file addition: README.md (----------)
    [0.1366]
    # X2ModBuildCommon
    An improved XCOM 2 mod build system. The following (in no praticular order) are its features/improvements over the default one:
    * Path rewriting for script errors/warnings so that they no longer point to the temporary copies of files in SDK/Developement/Src
    * Automated including of compile-time dependencies (including CHL) so that you no longer need to pollute your SrcOrig with them
    * Caching of `ModShaderCache` and invoking the shader precompiler only when the mod content files have changed
    * Proper cancelling of the build mid-way (instead of waiting until it completes)
    * Configurable mod workshop ID
    * Automated including of SDK's content packages (for those which are missing in the cooked game) so that you don't need to store them in your project
    * Full HL building: final release compiling and cooking of native script packages
    * Scriptable hooks in the build process
    * Conversion of localization file(s) encoding (UTF8 in the project for correct git merging and UTF16 for correct game loading)
    * Mod asset cooking (experimental)
    * Correct removal of files from the steamapps/XCOM2/WOTC/XComGame/Mods (built mod) when they are deleted from the project
    * Mod-defined global macros (without explicit `include`s and without messing with your `SrcOrig`)
    * Most features are configurable!
    # Getting started
    Foreword: the build system was designed to be flexible in how you want to set it up. This section describes
    the most common/basic setup that should work for 95% of mods out there. If you want to customize it, read the next section
    ## Getting the files
    First, create a `.scripts` folder in the root of your mod project (next to the `.XCOM_sln` file) - from now on referred
    to as `[modRoot]`. The next step depends on whether you are using git or not. Git is preferable but the build system
    will work just fine without it.
    ### Your mod uses git
    Open a command line prompt (cmd or powershell, does not matter) in the `[modRoot]`. Ensure that
    your working tree is clean and run the following command:
    ```
    git subtree add --prefix .scripts/X2ModBuildCommon https://github.com/X2CommunityCore/X2ModBuildCommon v1.2.1 --squash
    ```
    ### Your mod does not use git
    Download the source code of this repository from the latest release on the [Releases page](https://github.com/X2CommunityCore/X2ModBuildCommon/releases/latest).
    Unzip it and place so that `build_common.ps1` resides at `[modRoot]\.scripts\X2ModBuildCommon\build_common.ps1`.
    ## Ignoring the `BuildCache`
    The build system will create a `[modRoot]\BuildCache` folder which is used for various file-based operations (such
    as recompiling the `ModShaderCache` only when mod's content has changed). This folder is fully managed by the build
    system and normally you should never open it. It is also safe to delete at any time (e.g. if you want to
    force a full rebuild).
    As such, this folder is not meant to be shared with other developers working on the project or stored in
    backups/previous versions (e.g. when using a VCS) - this can lead to incorrect behaviour.
    If you are using git, you should add it (`BuildCache/`) to your `.gitignore`
    ## Setting up the build entrypoint
    Create `[modRoot]\.scripts\build.ps1` with the following content:
    ```ps1
    Param(
    [string] $srcDirectory, # the path that contains your mod's .XCOM_sln
    [string] $sdkPath, # the path to your SDK installation ending in "XCOM 2 War of the Chosen SDK"
    [string] $gamePath, # the path to your XCOM 2 installation ending in "XCOM2-WaroftheChosen"
    [string] $config # build configuration
    )
    $ScriptDirectory = Split-Path $MyInvocation.MyCommand.Path
    $common = Join-Path -Path $ScriptDirectory "X2ModBuildCommon\build_common.ps1"
    Write-Host "Sourcing $common"
    . ($common)
    $builder = [BuildProject]::new("YourProjectName", $srcDirectory, $sdkPath, $gamePath)
    switch ($config)
    {
    "debug" {
    $builder.EnableDebug()
    }
    "default" {
    # Nothing special
    }
    "" { ThrowFailure "Missing build configuration" }
    default { ThrowFailure "Unknown build configuration $config" }
    }
    $builder.InvokeBuild()
    ```
    Replace `YourProjectName` with the mod project name (e.g. the name of your `.XCOM_sln` file without the extension).
    If you're transitioning an existing mod to X2ModBuildCommon, this advice might come too late, but we recommend that
    the project name contain only ASCII alphabetic characters, numbers and underscores (matching the regular expression `^[A-Za-z][A-Za-z0-9_]*$`).
    The ModBuddy project generator lets you create projects with a large variety of characters that will break the ModBuddy
    build already (like brackets and dashes), but spaces and semicolons are allowed and work fine with the Firaxis ModBuddy plugin.
    `X2ModBuildCommon` will do its best to support project names with spaces, but it's historically been a common source of bugs
    and you may run into fewer of them if you keep your mod name simple.
    ## IDE integration
    At this point your mod is actually ready for building but invoking the powershell script with all the arguments each time manually
    is not convinient. Instead, we would like it to be invoked automatically when we press the build button in our IDE
    ### ModBuddy
    Close Modbuddy (or at least the solution) if you have it open. Open your `.x2proj` (in something like notepad++) and find the follwing line:
    ```xml
    <Import Project="$(MSBuildLocalExtensionPath)\XCOM2.targets" />
    ```
    Replace it with following:
    ```xml
    <PropertyGroup>
    <SolutionRoot>$(MSBuildProjectDirectory)\..\</SolutionRoot>
    <ScriptsDir>$(SolutionRoot).scripts\</ScriptsDir>
    <BuildCommonRoot>$(ScriptsDir)X2ModBuildCommon\</BuildCommonRoot>
    </PropertyGroup>
    <Import Project="$(BuildCommonRoot)XCOM2.targets" />
    ```
    Note that the build tool does not care about most of the `.x2proj` file and will
    copy and compile files not referenced by the project file without issuing warnings.
    Consider using a tool like [Xymanek/X2ProjectGenerator](https://github.com/Xymanek/X2ProjectGenerator)
    to automatically ensure the file list in ModBuddy accurately lists the files part of the project.
    ### VSCode
    > FIXME(#1): Rename variables to remove HL references?
    First, you need to tell Visual Studio code where to find the game and SDK (similar to the first-time ModBuddy setup).
    To do that, open the "Settings (JSON)" file by using the "Ctrl+Shift+P" shortcut and running "Preferences: Open Settings (JSON)"
    or by clicking "File->Preferences->Settings" and clicking the "Open Settings (JSON)" button on the tab bar. Add the following
    two entries, adjusting paths as necessary.
    ```json
    "xcom.highlander.sdkroot": "d:\\Steam\\SteamApps\\common\\XCOM 2 War of the Chosen SDK",
    "xcom.highlander.gameroot": "d:\\Steam\\SteamApps\\common\\XCOM 2\\XCom2-WarOfTheChosen"
    ```
    VS Code may tell you that the configuration settings are unknown. This is acceptable and can be ignored.
    Next up, you have to tell VS code about your build tasks. Create a folder `.vscode` next to the `.scripts` folder,
    and within it create a `tasks.json` file with the following content (replacing `MY_MOD_NAME` with the mod project
    name in the "Clean" task):
    ```json
    {
    "version": "2.0.0",
    "tasks": [
    {
    "label": "Build",
    "type": "shell",
    "command": "powershell.exe –NonInteractive –ExecutionPolicy Unrestricted -file '${workspaceRoot}\\.scripts\\build.ps1' -srcDirectory '${workspaceRoot}' -sdkPath '${config:xcom.highlander.sdkroot}' -gamePath '${config:xcom.highlander.gameroot}' -config 'default'",
    "group": "build",
    "problemMatcher": []
    },
    {
    "label": "Build debug",
    "type": "shell",
    "command": "powershell.exe –NonInteractive –ExecutionPolicy Unrestricted -file '${workspaceRoot}\\.scripts\\build.ps1' -srcDirectory '${workspaceRoot}' -sdkPath '${config:xcom.highlander.sdkroot}' -gamePath '${config:xcom.highlander.gameroot}' -config 'debug'",
    "group": "build",
    "problemMatcher": []
    },
    {
    "label": "Clean",
    "type": "shell",
    "command": "powershell.exe –NonInteractive –ExecutionPolicy Unrestricted -file '${workspaceRoot}\\.scripts\\clean.ps1' -modName 'MY_MOD_NAME' -srcDirectory '${workspaceRoot}' -sdkPath '${config:xcom.highlander.sdkroot}' -gamePath '${config:xcom.highlander.gameroot}'",
    "group": "build",
    "problemMatcher": []
    },
    {
    "label": "Full rebuild",
    "dependsOrder": "sequence",
    "dependsOn": ["Clean", "Build"]
    }
    ]
    }
    ```
    Note that the `-config 'debug'` or `-config 'default'` build configurations correspond to
    the build configurations in the `build.ps1` entry point created earlier. You can easily add
    existing build tasks with custom configurations by modifying `build.ps1` and configuring the
    `$builder` (see just below!)
    > FIXME(microsoft/vscode#24865): Add problem matchers when they can be shared between tasks.
    ## Ready!
    You can now successfully build your mod from your IDE using X2ModBuildCommon. Keep reading on to find about what you can configure.
    ## Updating
    The build system is desinged to be version-pinned against your mod - you can continue using the old version as long as it suits your needs, even if a new one is released. If you would like to get the new features/improvements/bugfixes of the new version, the update procedure is simple.
    If you don't use git, simply download the new version and overwrite the old files inside the `X2ModBuildCommon` folder.
    If you use git, run the same command as before, replacing `add` with `pull`:
    ```
    git subtree pull --prefix .scripts/X2ModBuildCommon https://github.com/X2CommunityCore/X2ModBuildCommon v1.2.1 --squash
    ```
    # Configuration options
    All the following examples are modifications that could be made to your `build.ps1`.
    ## ThrowFailure
    > FIXME: `ThrowFailure` vs `FailureMessage`?
    Throw a failure. Example usage:
    ```ps1
    switch ($config) {
    # ...
    "" { ThrowFailure "Missing build configuration" }
    }
    ```
    ## SetWorkshopID
    Override the workshop ID from the x2proj file. Example usage:
    ```ps1
    # make sure beta builds are never uploaded to the stable workshop page
    if ($config -eq "stable") {
    $builder.SetWorkshopID(1234567890)
    }
    else {
    $builder.SetWorkshopID(6789012345)
    }
    ```
    ## EnableFinalRelease
    Pass the `-final_release` flag to the compiler for base-game script packages and the Highlander cooker.
    Can only be used for Highlander-style mods that modify native packages. Example usage:
    ```ps1
    switch ($config) {
    # ...
    "final_release" {
    $builder.EnableFinalRelease()
    }
    "stable" {
    $builder.EnableFinalRelease()
    }
    }
    ```
    ## EnableDebug
    Pass the `-debug` flag to all script compiler invocations. Incompatible with `EnableFinalRelease`, and will skip
    Highlander cooking process (accordingly you have to use `-noseekfreeloading` when launching the game). Example usage:
    ```ps1
    switch ($config) {
    # ...
    "debug" {
    $builder.EnableDebug()
    }
    }
    ```
    ## AddPreMakeHook
    Add a callback to be executed after all script sources have been added to `Src` but before the compiler is run.
    Example usage:
    ```ps1
    # Checks if a certain automatically generated file actually compiles, but only with the "compiletest" configuration
    if ($compiletest) {
    $builder.AddPreMakeHook({
    Write-Host "Including CHL_Event_Compiletest"
    # n.b. this copies from the `target` directory where it is generated into, see tasks.json
    Copy-Item "..\target\CHL_Event_Compiletest.uc" "$sdkPath\Development\Src\X2WOTCCommunityHighlander\Classes\" -Force -WarningAction SilentlyContinue
    })
    }
    ```
    The Highlander also uses it to embed the current git commit hash in some source files.
    ## IncludeSrc
    Add dependencies' source files to `Src`. This removes the step where mods whose sources you want to have available
    have to be copied to `SrcOrig`. Example usage (from Covert Infiltration):
    ```
    $builder.IncludeSrc("$srcDirectory\X2WOTCCommunityHighlander\X2WOTCCommunityHighlander\Src")
    $builder.IncludeSrc("$srcDirectory\X2WOTCCommunityHighlander\Components\DLC2CommunityHighlander\DLC2CommunityHighlander\Src")
    $builder.IncludeSrc("$srcDirectory\SquadSelectAnyTime\SquadSelectAtAnyTime\Src")
    ```
    ## AddToClean
    Deletes certain built mods from `SDK/XComGame/Mods`. Usually necessary for dependencies since their script compiler configuration
    files can cause the script compiler to choke. Covert Infiltration does this:
    ```ps1
    $builder.AddToClean("SquadSelectAtAnyTime")
    ```
    ## Content options
    You can provide a "content options" file that will determine some additional content-related steps. This file should be checked
    in to your VSC (e.g. tracked by git) and must reside next to your `.x2proj` (note that the file will not be included in the
    final built mod). If using Modbuddy, you can add the file to the project for easier editing.
    Assuming the file is named `ContentOptions.json`:
    ```ps1
    $builder.SetContentOptionsJsonFilename("ContentOptions.json")
    ```
    Four options are available: `missingUncooked`, `sfStandalone`, `sfMaps`, `sfCollectionMaps`. Omitting an option (or the file entirely)
    is treated the same as setting it to an empty array
    ### Including missing uncooked
    In case your mod depends on some assets that were not shipped in a seek free package, you can automatically include it with your mod.
    Example from Covert Infiltration:
    ```json
    {
    "missingUncooked": [
    "CIN_TroopTransport.upk",
    "PCP_Archetypes_XPACK.upk"
    ]
    }
    ```
    **IMPORTANT**: you need to be on the `full_content` branch of the SDK for this to work.
    ### Asset cooking
    The rest of the options are for the mod assets cooking. Because it is such a complex process, the package and map configuration is
    described in a separate file. See [Asset Cooking](https://github.com/X2CommunityCore/X2ModBuildCommon/wiki/Asset-cooking) for details.
    # Additional features
    ## extra_globals
    This isn't a configuration option, but mods can create an `extra_globals.uci` file in the
    `Src` folder to have the build tool append its contents to `Globals.uci`. This allows mods
    to use custom macros.
    Moreover, the `extra_globals.uci` files of any dependencies added via `IncludeSrc` will be merged into `Globals.uci` too. This allows dependency mods to safely use custom macros
    without causing compilation problems for dependent mods.
    ## Localization Encoding
    Any files in the `Localization` folder will have its encoding rewritten from UTF-8 to UTF-16. This allows tracking
    localization files in the git-compatible UTF-8 text encoding even though the game only supports ASCII and UTF-16.
    # ModBuddy project customization
    You can customize your `.x2proj` using the following properties:
    Property | Default value | Notes
    -------- | ------------- | -----
    `SolutionRoot` | None (**required**) | The path to folder which houses your `.XCOM_sln` file
    `ScriptsDir` | None | Required if you don't set `BuildEntryPs1`. Ignored otherwise
    `BuildCommonRoot` | None (**required**) | The path to folder which houses the `InvokePowershellBuild.cs` file
    `BuildEntryFileName` | `build.ps1` | Required if you don't set `BuildEntryPs1`. Ignored otherwise
    `BuildEntryPs1` | `$(ScriptsDir)$(BuildEntryFileName)` |
    `BuildEntryConfig` | `default` or `debug` | Will be passed to your build entrypoint. Default is derived from `$(Configuration)` (build configuration dropdown)
    Hint: if you want to add other build configurations, you can let the `default` and `debug` ones be handeled
    by the provided `XCOM2.targets`. Example from CHL:
    ```xml
    <PropertyGroup>
    <SolutionRoot>$(MSBuildProjectDirectory)\..\</SolutionRoot>
    <ScriptsDir>$(SolutionRoot).scripts\</ScriptsDir>
    <BuildCommonRoot>$(ScriptsDir)X2ModBuildCommon\</BuildCommonRoot>
    <!-- Default and debug are handeled by the .targets automatically -->
    <BuildEntryConfig Condition=" '$(Configuration)' == 'Final release' ">final_release</BuildEntryConfig>
    <BuildEntryConfig Condition=" '$(Configuration)' == 'Compiletest' ">compiletest</BuildEntryConfig>
    <BuildEntryConfig Condition=" '$(Configuration)' == 'Workshop stable version' ">stable</BuildEntryConfig>
    </PropertyGroup>
    ```
    # Things to watch out for
    Note that you can always check the issue tracker: https://github.com/X2CommunityCore/X2ModBuildCommon/issues?q=is%3Aissue+is%3Aopen+label%3Abug
    ## Deleting content files
    If you **delete** content files (e.g. moved to a different mod or just completely removed) the current caching logic (e.g. for the
    shader cache) might not recognize it. As such, it's recommended that you simply delete the `BuildCache` folder in such cases.
  • file addition: LICENSE (----------)
    [0.1366]
    MIT License
    Copyright (c) 2021 X2CommunityCore
    Permission is hereby granted, free of charge, to any person obtaining a copy
    of this software and associated documentation files (the "Software"), to deal
    in the Software without restriction, including without limitation the rights
    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    copies of the Software, and to permit persons to whom the Software is
    furnished to do so, subject to the following conditions:
    The above copyright notice and this permission notice shall be included in all
    copies or substantial portions of the Software.
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    SOFTWARE.
  • file addition: InvokePowershellTask.cs (----------)
    [0.1366]
    using System;
    using System.IO;
    using System.Threading;
    using System.Management.Automation;
    using Microsoft.Build.Framework;
    using Microsoft.Build.Utilities;
    public class InvokePowershellTask : Task, ICancelableTask
    {
    [Required] public string EntryPs1 { get; set; }
    [Required] public string SolutionRoot { get; set; }
    [Required] public string SdkInstallPath { get; set; }
    [Required] public string GameInstallPath { get; set; }
    [Required] public ITaskItem[] AdditionalArgs { get; set; }
    private PowerShell _ps;
    private ManualResetEventSlim _startingMre = new ManualResetEventSlim(false);
    public override bool Execute()
    {
    bool isSuccess = false;
    try
    {
    _ps = PowerShell.Create();
    _ps
    .AddCommand("Set-ExecutionPolicy")
    .AddArgument("Unrestricted")
    .AddParameter("Scope","CurrentUser");
    _ps
    .AddStatement()
    .AddCommand(EntryPs1)
    .AddParameter("srcDirectory", TrimEndingDirectorySeparator(SolutionRoot))
    .AddParameter("sdkPath", TrimEndingDirectorySeparator(SdkInstallPath))
    .AddParameter("gamePath", TrimEndingDirectorySeparator(GameInstallPath));
    foreach (ITaskItem Arg in AdditionalArgs)
    {
    string Val = Arg.GetMetadata("Value");
    if (string.IsNullOrEmpty(Val))
    {
    _ps.AddParameter(Arg.ItemSpec);
    }
    else
    {
    _ps.AddParameter(Arg.ItemSpec, Val);
    }
    }
    BindStreamEntryCallback(_ps.Streams.Debug, record => LogOutput(record.ToString()));
    BindStreamEntryCallback(_ps.Streams.Information, record => LogOutput(record.ToString()));
    BindStreamEntryCallback(_ps.Streams.Verbose, record => LogOutput(record.ToString()));
    BindStreamEntryCallback(_ps.Streams.Warning, record => LogOutput(record.ToString())); // TODO: More flashy output?
    BindStreamEntryCallback(_ps.Streams.Error, record =>
    {
    // TODO: Less info than when from console
    // TODO: More flashy output?
    LogOutput(record.ToString());
    Log.LogError(record.ToString());
    isSuccess = false;
    });
    _ps.InvocationStateChanged += (sender, args) =>
    {
    if (args.InvocationStateInfo.State == PSInvocationState.Running)
    {
    _startingMre.Set();
    }
    };
    isSuccess = true;
    _ps.Invoke();
    }
    catch (System.Exception e)
    {
    Log.LogError(e.Message);
    isSuccess = false;
    }
    return isSuccess;
    }
    public void Cancel()
    {
    // Log.LogMessage(MessageImportance.High, "Got cancel");
    // Do not call Stop() until we know that we've actually started
    // This could be more elaborate, but the time interval between Execute() and Invoke() being called is extremely small
    _startingMre.Wait();
    _ps.Stop();
    }
    private void LogOutput (string output)
    {
    // This is required to keep the empty lines in the output
    if (string.IsNullOrEmpty(output)) output = " ";
    Log.LogMessage(MessageImportance.High, output);
    }
    private static readonly char[] DirectorySeparatorsForTrimming = new char[]
    {
    Path.DirectorySeparatorChar,
    Path.AltDirectorySeparatorChar
    };
    private static string TrimEndingDirectorySeparator(string path)
    {
    return path.TrimEnd(DirectorySeparatorsForTrimming);
    }
    private static void BindStreamEntryCallback<T>(PSDataCollection<T> stream, Action<T> handler)
    {
    stream.DataAdded += (object sender, DataAddedEventArgs e) => handler(stream[e.Index]);
    }
    }
  • file addition: EmptyUMap (----------)
    [0.1366]
  • file addition: CHANGELOG.md (----------)
    [0.1366]
    ## Next
    ## 1.2.1 (2021-12-22)
    * Support mods without script packages
    ## 1.2.0 (2021-12-19)
    * Significant improvements/rework of the asset cooking step (#70)
    ## 1.1.2 (2021-11-30)
    * Shader precompile step is no longer triggered by maps
    * Asset cooking step no longer overwrites `DefaultEngine.ini` in the SDK
    ## 1.1.1 (2021-08-11)
    * Support Rebuild ModBuddy target
    * Internal improvements and fixes to asset cooking functionality
    * Support projects with spaces in path (#55)
    * Fix cryptic error about `SteamPublishID` for some projects (#56)
    * Fail the build in case cooking cleanup fails, preventing silent SDK corruption (#54)
    * Properly rewrite error messages originating from `IncludeSrc`-ed files (#45)
    ## 1.1.0 (2021-06-15)
    * Remove compiled script packages when switching between debug and release mode to prevent compiler error (#16)
    * Remove compiled script packages when modifying macros (#20)
    * Overridden Steam UGC IDs can now be `long` (`int64`) (#22)
    * Use error syntax `file(line)` for compiler errors to be compatible with both ModBuddy and VS Code (#26)
    * Add a `clean.ps1` script, ModBuddy configuration and VS Code example task to remove all cached build artifacts (#24)
    * Remove project file verification. Consider using [Xymanek/X2ProjectGenerator](https://github.com/Xymanek/X2ProjectGenerator) instead (#28)
    * Catch macro name clashes through `extra_globals.uci` (#30)
    * Add debugging option to profile build times (#35)
    ## 1.0.0 (2021-05-22)
    * Initial release