diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a53fdb6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +## Next + +## 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 diff --git a/EmptyUMap b/EmptyUMap new file mode 100644 index 0000000..0cf3c47 Binary files /dev/null and b/EmptyUMap differ diff --git a/InvokePowershellTask.cs b/InvokePowershellTask.cs new file mode 100644 index 0000000..2717a45 --- /dev/null +++ b/InvokePowershellTask.cs @@ -0,0 +1,122 @@ +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(PSDataCollection stream, Action handler) + { + stream.DataAdded += (object sender, DataAddedEventArgs e) => handler(stream[e.Index]); + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cacdc4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..40b12b3 --- /dev/null +++ b/README.md @@ -0,0 +1,383 @@ +# 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.1.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 + +``` + +Replace it with following: + +```xml + + $(MSBuildProjectDirectory)\..\ + $(SolutionRoot).scripts\ + $(ScriptsDir)X2ModBuildCommon\ + + +``` + +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.1.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 + + $(MSBuildProjectDirectory)\..\ + $(SolutionRoot).scripts\ + $(ScriptsDir)X2ModBuildCommon\ + + + final_release + compiletest + stable + +``` + +# 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. diff --git a/XCOM2.targets b/XCOM2.targets new file mode 100644 index 0000000..b3d0bd6 --- /dev/null +++ b/XCOM2.targets @@ -0,0 +1,92 @@ + + + + + Program + $(XCOM2_GamePath)\..\Binaries\Win64\Launcher\StartDebugging.bat + + + . + + + . + + + . + + + $(MSBuildProjectDirectory) + $(XCOM2_UserPath)\Mods\ + $(ModsDir)$(SafeName)\ + + + + + + + + + + build.ps1 + $(ScriptsDir)$(BuildEntryFileName) + + + + + default + debug + + + + + + + $(SafeName) + + + + + + + + + + + + + + + $(BuildEntryConfig) + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build_common.ps1 b/build_common.ps1 new file mode 100644 index 0000000..95f9756 --- /dev/null +++ b/build_common.ps1 @@ -0,0 +1,1384 @@ +Write-Host "Build Common Loading" + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version 3.0 + +$global:def_robocopy_args = @("/S", "/E", "/COPY:DAT", "/PURGE", "/MIR", "/NP", "/R:1000000", "/W:30") +$global:buildCommonSelfPath = split-path -parent $MyInvocation.MyCommand.Definition +# list of all native script packages +$global:nativescriptpackages = @("XComGame", "Core", "Engine", "GFxUI", "AkAudio", "GameFramework", "UnrealEd", "GFxUIEditor", "IpDrv", "OnlineSubsystemPC", "OnlineSubsystemLive", "OnlineSubsystemSteamworks", "OnlineSubsystemPSN") + +$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] $modcookdir + [string] $makeFingerprintsPath + [string[]] $thismodpackages + [bool] $isHl + [bool] $cookHL + [PSCustomObject] $contentOptions + + 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() + $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") + $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") + $this._PerformStep({ ($_)._RunMakeMod() }, "Compiling", "Compiled", "mod script packages") + $this._RecordCoreTimestamp() + if ($this.isHl) { + if (-not $this.debug) { + $this._PerformStep({ ($_)._RunCookHL() }, "Cooking", "Cooked", "Highlander packages") + } else { + Write-Host "Skipping cooking as debug build" + } + } + $this._PerformStep({ ($_)._CopyScriptPackages() }, "Copying", "Copied", "compiled script packages") + + # The shader step needs to happen before 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 + $this.thismodpackages = Get-ChildItem "$($this.modSrcRoot)/Src" -Directory + + $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.modcookdir = [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.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." + + 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." + + # 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.thismodpackages) + { + if ($global:nativescriptpackages.Contains($name)) { + $anynative = $true + break + } + } + return $anynative + } + + [void]_CopyScriptPackages() { + # copy packages to staging + foreach ($name in $this.thismodpackages) { + if ($this.cookHL -and $global:nativescriptpackages.Contains($name)) + { + # This is a native (cooked) script package -- copy important upks + Copy-Item "$($this.modcookdir)\$name.upk" "$($this.stagingPath)\CookedPCConsole" -Force -WarningAction SilentlyContinue + Copy-Item "$($this.modcookdir)\$name.upk.uncompressed_size" "$($this.stagingPath)\CookedPCConsole" -Force -WarningAction SilentlyContinue + Write-Host "$($this.modcookdir)\$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 = @() + + if (Test-Path "$($this.modSrcRoot)/Content") + { + $contentfiles = $contentfiles + (Get-ChildItem "$($this.modSrcRoot)/Content" -Include *.upk, *.umap -Recurse -File) + } + + # Turns out that seekfree packages contain an inlined shader cache, so there is no need to pass them through the shader precompiler + # if (Test-Path "$($this.modSrcRoot)/ContentForCook") + # { + # $contentfiles = $contentfiles + (Get-ChildItem "$($this.modSrcRoot)/ContentForCook" -Include *.upk, *.umap -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() { + # 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') + if(-not(Test-Path $this.modcookdir)) + { + Write-Host "Creating Published/CookedPCConsole directory..." + New-Item $this.modcookdir -ItemType Directory + } + + [System.String[]]$files = "GuidCache.upk", "GlobalPersistentCookerData.upk", "PersistentCookerShaderData.bin" + foreach ($name in $files) { + if(-not(Test-Path ([io.path]::combine($this.modcookdir, $name)))) + { + Write-Host "Copying $name..." + Copy-Item ([io.path]::combine($cookedpcconsoledir, $name)) $this.modcookdir + } + } + + # 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.modcookdir)" *.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]_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 + } + + [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) + } +} + +class ModAssetsCookStep { + [BuildProject] $project + + [string] $tfcSuffix + [string[]] $cookedMaps + [bool] $firstCook = $false + + [string] $contentForCookPath + [string] $collectionMapsPath + + [string] $cookerOutputPath + [string] $cachedCookerOutputPath + + [string] $cachedReleaseScriptPackagesDir + + [string] $sdkEngineIniPath + [string] $sdkEngineIniContentOriginal + [string] $sdkEngineIniChangesPreamble = "HACKS FOR MOD ASSETS COOKING" + + [string[]] $filesRequiredToSkipFirstPass + + [string] $sdkEngineIniContentNewFirstPass + [string] $sdkEngineIniContentNewNormalPass + + [string] $previousCookerOutputDirPath = $null + + [string] $editorArgsFirstPass + [string] $editorArgsNormalPass + + # Doesn't include the editor-only packages and the missing ones (e.g. PSN subsystem) + [string[]] $cookedNativeScriptPackages = @("XComGame", "Core", "Engine", "GFxUI", "AkAudio", "GameFramework", "IpDrv", "OnlineSubsystemSteamworks") + + ModAssetsCookStep ([BuildProject] $project) { + $this.project = $project + } + + [void] Execute() { + if (($this.project.contentOptions.sfStandalone.Length -lt 1) -and ($this.project.contentOptions.sfMaps.Length -lt 1) -and ($this.project.contentOptions.sfCollectionMaps.Length -lt 1)) { + Write-Host "No asset cooking is requested, skipping" + return + } + + Write-Host "Initializing assets cooking" + + $this._Init() + $this._Verify() + + Write-Host "Preparing assets cooking" + + $this._PrepareReleaseScriptPackages() + $this._PrepareProjectCache() + $this._PrepareSdkEngineIni() + $this._PrepareSdkFolders() + $this._PrepareEditorArgs() + + Write-Host "Starting assets cooking" + + $this._ExecuteCore() + $this._StageArtifacts() + + Write-Host "Assets cook completed" + } + + [void] _Init() { + $this.tfcSuffix = "_Mod_$($this.project.modNameCanonical)_" + + $this.cookerOutputPath = [io.path]::combine($this.project.sdkPath, 'XComGame', 'Published', 'CookedPCConsole') + $this.cachedCookerOutputPath = [io.path]::combine($this.project.buildCachePath, 'PublishedCookedPCConsole') + + $this.contentForCookPath = "$($this.project.modSrcRoot)\ContentForCook" + $this.collectionMapsPath = [io.path]::combine($this.project.buildCachePath, 'CollectionMaps') + + $this.cachedReleaseScriptPackagesDir = [io.path]::combine($this.project.buildCachePath, 'ReleaseScriptPackages') + + $this.sdkEngineIniPath = "$($this.project.sdkPath)/XComGame/Config/DefaultEngine.ini" + $this.sdkEngineIniContentOriginal = Get-Content $this.sdkEngineIniPath | Out-String + + $this.filesRequiredToSkipFirstPass = @("GlobalPersistentCookerData.upk", "gfxCommon_SF.upk") + foreach ($package in $this.cookedNativeScriptPackages) { + $this.filesRequiredToSkipFirstPass += "$package.upk" + $this.filesRequiredToSkipFirstPass += "$package.upk.uncompressed_size" + } + + $this.cookedMaps = $this.project.contentOptions.sfMaps + foreach ($mapDef in $this.project.contentOptions.sfCollectionMaps) { + $this.cookedMaps += $mapDef.name + } + } + + [void] _Verify() { + if (-not(Test-Path $this.contentForCookPath)) + { + ThrowFailure "Asset cooking is requested, but no ContentForCook folder is present" + } + + if ($this.sdkEngineIniContentOriginal.Contains($this.sdkEngineIniChangesPreamble)) + { + ThrowFailure "Another cook is already in progress (DefaultEngine.ini)" + } + } + + [void] _PrepareReleaseScriptPackages () { + if (!(Test-Path $this.cachedReleaseScriptPackagesDir)) + { + New-Item -ItemType "directory" -Path $this.cachedReleaseScriptPackagesDir + } + + if (!$this.project.debug) { + # Store the release packages for next potential debug builds + Robocopy.exe "$($this.project.sdkPath)\XComGame\Script" $this.cachedReleaseScriptPackagesDir *.* $global:def_robocopy_args + } + else { + # Figure out which packages we need + $required = $this.cookedNativeScriptPackages + + # Make sure we have all of them ready for use + $missing = @() + + foreach ($package in $required) { + if (!(Test-Path "$($this.cachedReleaseScriptPackagesDir)\$package.u")) { + $missing += $package + } + } + + if ($missing.Length -gt 0) { + Write-Host "Missing cached release script packages: $missing" + ThrowFailure "Missing cached release script packages - cannot cook assets. Please build the mod in release (aka default) once to cache them" + } + + Write-Host "" + Write-Host "Using cached release script packages" + Write-Host "If you've made changes to (or added) classes which are referenced by assets, please rebuild the mod in release (aka default) once to cache them" + Write-Host "" + } + } + + [void] _PrepareProjectCache() { + if (!(Test-Path $this.cachedCookerOutputPath)) + { + New-Item -ItemType "directory" -Path $this.cachedCookerOutputPath + $this.firstCook = $true + } else { + $this.firstCook = $this._IsFirstPassRequired() + + if ($this.firstCook) { + # Empty the cooker output dir + Remove-Item "$($this.cachedCookerOutputPath)\*" -Force -Recurse + } + } + + # 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 + + # Collection maps also need the actual empty umap file created + # (unless it's already provided for w/e reason) + foreach ($mapDef in $this.project.contentOptions.sfCollectionMaps) { + if ($null -eq (Get-ChildItem -Path $this.contentForCookPath -Filter $mapDef.name -Recurse)) { + # 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)\$($mapDef.name).umap" + } + } + } + + [bool] _IsFirstPassRequired() { + foreach ($file in $this.filesRequiredToSkipFirstPass) { + $path = [io.path]::combine($this.cachedCookerOutputPath, $file) + + if (!(Test-Path $path)) { + Write-Host "$file is missing, forcing first cook" + return $true + } + } + + return $false + } + + [void] _PrepareSdkEngineIni() { + $original = $this.sdkEngineIniContentOriginal + "`n" + $additionsShared = $this._BuildEngineIniAdditionsShared() + "`n" + + $this.sdkEngineIniContentNewFirstPass = $original + $additionsShared + $this.sdkEngineIniContentNewNormalPass = $original + $additionsShared + $this._BuildEngineIniAdditionsNormalPass() + + # Backup the DefaultEngine.ini + Copy-Item $this.sdkEngineIniPath "$($this.sdkEngineIniPath).bak_PRE_ASSET_COOKING" + } + + [string] _BuildEngineIniAdditionsShared () { + $lines = @() + + # Denote the beginning of our changes (this marker is used by _Verify to detect unfinished cook) + $lines += "; $($this.sdkEngineIniChangesPreamble) - $($this.project.modNameCanonical)" + + # "Inject" our assets into the SDK to make them visible to the cooker + $lines += "[Core.System]" + $lines += "+Paths=$($this.contentForCookPath)" + $lines += "+Paths=$($this.collectionMapsPath)" + + # Redirect to cached release script packages to support debug builds + $lines += "ScriptPaths=$($this.cachedReleaseScriptPackagesDir)" + + # Remove default seek free packages + # This will trump the rest of file content as it's all the way at the bottom + $lines += "[Engine.PackagesToAlwaysCook]" + $lines += "!SeekFreePackage=Empty" + + return $lines -join "`n" + } + + [string] _BuildEngineIniAdditionsNormalPass () { + $lines = @() + + # Don't re-cook the startup (cooker doesn't cache it) + $lines += "[Engine.StartupPackages]" + $lines += "!Package=Empty" + + # SF Standalone packages + $lines += "[Engine.PackagesToAlwaysCook]" + foreach ($package in $this.project.contentOptions.sfStandalone) { + $lines += "+SeekFreePackage=$package" + } + + # Collection maps + $lines += "[Engine.PackagesToForceCookPerMap]" + foreach ($mapDef in $this.project.contentOptions.sfCollectionMaps) { + $lines += "+Map=$($mapDef.name)" + + foreach ($package in $mapDef.packages) { + $lines += "+Package=$package" + } + } + + return $lines -join "`n" + } + + [void] _PrepareSdkFolders () { + $cookOutputParentDir = [io.path]::combine($this.project.sdkPath, 'XComGame', 'Published') + + if (-not (Test-Path -Path $cookOutputParentDir)) { + New-Item -Path $cookOutputParentDir -Type Directory + } + elseif (Test-Path $this.cookerOutputPath) { + $previousCookerOutputDirName = "Pre_$($this.project.modNameCanonical)_Cook_CookedPCConsole" + $this.previousCookerOutputDirPath = [io.path]::combine($this.project.sdkPath, 'XComGame', 'Published', $previousCookerOutputDirName) + + Rename-Item $this.cookerOutputPath $this.previousCookerOutputDirPath + } + } + + [void] _PrepareEditorArgs () { + $cookerFlags = "-platform=pcconsole -skipmaps -modcook -TFCSUFFIX=$($this.tfcSuffix) -singlethread -unattended -usermode" + + $mapsString = "" + for ($i = 0; $i -lt $this.cookedMaps.Length; $i++) + { + $umap = $this.cookedMaps[$i] + $mapsString = "$mapsString $umap.umap " + } + + $this.editorArgsFirstPass = "CookPackages $cookerFlags" + $this.editorArgsNormalPass = "CookPackages $mapsString $cookerFlags" + } + + [void] _ExecuteCore () { + # This try block needs to be kept as small as possible as it puts the SDK into a (temporary) invalid state + try { + # Redirect all the cook output to our local cache + # This allows us to not recook everything when switching between projects (e.g. CHL) + New-Junction $this.cookerOutputPath $this.cachedCookerOutputPath + + if ($this.firstCook) { + # First do a cook without our assets since gfxCommon.upk still get included in the cook, polluting the TFCs, depsite the config hacks + + Write-Host "Running first time mod assets cook" + $this._InvokeAssetCooker($this.editorArgsFirstPass, $this.sdkEngineIniContentNewFirstPass) + + # Now delete the polluted TFCs + Get-ChildItem -Path $this.cachedCookerOutputPath -Filter "*$($this.tfcSuffix).tfc" | Remove-Item + + # And make sure scripts are never cooked again + $this._SetTimestampOnCookedScript() + + Write-Host "First time cook done, proceeding with normal" + } + + $this._InvokeAssetCooker($this.editorArgsNormalPass, $this.sdkEngineIniContentNewNormalPass) + } + finally { + Write-Host "Cleaning up the asset cooking hacks" + $cleanupFailed = $false + + # Revert ini + try { + $this.sdkEngineIniContentOriginal | Set-Content $this.sdkEngineIniPath -NoNewline + Write-Host "Reverted $($this.sdkEngineIniPath)" + } + catch { + FailureMessage "Failed to revert $($this.sdkEngineIniPath)" + FailureMessage $_ + + $cleanupFailed = $true + } + + # Revert junctions + + try { + Remove-Junction $this.cookerOutputPath + Write-Host "Removed $($this.cookerOutputPath) junction" + } + catch { + FailureMessage "Failed to remove $($this.cookerOutputPath) junction" + FailureMessage $_ + + $cleanupFailed = $true + } + + if (![string]::IsNullOrEmpty($this.previousCookerOutputDirPath)) + { + try { + if (Test-Path $this.cookerOutputPath) { + ThrowFailure "$($this.cookerOutputPath) still exists, cannot restore previous" + } + + Rename-Item $this.previousCookerOutputDirPath "CookedPCConsole" + Write-Host "Restored previous $($this.cookerOutputPath)" + } + catch { + FailureMessage "Failed to restore previous $($this.cookerOutputPath)" + 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] _InvokeAssetCooker ([string] $editorArguments, [string] $engineIniContentNew) { + Write-Host $editorArguments + + $engineIniContentNew | Set-Content $this.sdkEngineIniPath -NoNewline + + $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] _SetTimestampOnCookedScript () { + $newTimestamp = (Get-Date).AddYears(30) + $files = @() + + foreach ($package in $this.cookedNativeScriptPackages) { + $files += "$package.upk" + $files += "$package.upk.uncompressed_size" + } + + foreach ($file in $files) { + $path = [io.path]::Combine($this.cachedCookerOutputPath, $file) + + if (Test-Path $path) { + (Get-Item $path).LastWriteTime = $newTimestamp + } + } + } + + [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 + Get-ChildItem -Path $this.cachedCookerOutputPath -Filter "*$($this.tfcSuffix).tfc" | Copy-Item -Destination $stagingCookedDir + + # Copy over the maps + for ($i = 0; $i -lt $this.cookedMaps.Length; $i++) + { + $umap = $this.cookedMaps[$i]; + Copy-Item "$($this.cachedCookerOutputPath)\$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"); + + # Since we don't ship the GuidCache with the mod, we need to remove the _SF suffix. + # Otherwise the game won't find the package + Copy-Item "$($this.cachedCookerOutputPath)\${package}_SF.upk" -Destination $dest + } + } +} + +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 + [bool] $permitAdditional = $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("Adding package") -or $outTxt.StartsWith("Adding level") -or $outTxt.StartsWith("GFx movie package")) { + if ($outTxt.Contains("\BuildCache\") -or $outTxt.Contains("\ContentForCook\")) { + $permitLine = $true + } else { + $permitLine = $false + + if (!$this.lastLineWasAdding) { + Write-Host "[Adding sdk assets ...]" + } + } + + $this.lastLineWasAdding = !$permitLine + $this.permitAdditional = $permitLine + } elseif ($outTxt.StartsWith("Adding additional")) { + $permitLine = $this.permitAdditional + } 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) +} + +function New-Junction ([string] $source, [string] $destination) { + Write-Host "Creating Junction: $source -> $destination" + &"$global:buildCommonSelfPath\junction.exe" -nobanner -accepteula "$source" "$destination" +} + +function Remove-Junction ([string] $path) { + Write-Host "Removing Junction: $path" + &"$global:buildCommonSelfPath\junction.exe" -nobanner -accepteula -d "$path" +} + +# 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 +} diff --git a/clean.ps1 b/clean.ps1 new file mode 100644 index 0000000..95fb301 --- /dev/null +++ b/clean.ps1 @@ -0,0 +1,41 @@ +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 + +if ($null -eq $modName -or $modName -eq "") { + throw "`$modName empty???" +} + +Write-Host "Deleting all cached build artifacts..." + +$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 +} + +Write-Host "Cleaned." diff --git a/junction.exe b/junction.exe new file mode 100644 index 0000000..29e42c8 Binary files /dev/null and b/junction.exe differ