diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fbc7fbf37..38032079d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,6 +5,8 @@ on: [push]
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ POWERSHELL_TELEMETRY_OPTOUT: 1
+ POWERSHELL_UPDATECHECK: Off
jobs:
windows:
@@ -24,6 +26,11 @@ jobs:
with:
fetch-depth: 0
+ - name: Verify Prebuild Script (Pester Tests)
+ shell: powershell
+ run: |
+ Invoke-Pester -Path "UI/tests/prebuild_windows.test.ps1" -Passthru | ForEach-Object { if ($_.FailedCount -gt 0) { exit 1 } }
+
- name: Install .NET Core
uses: actions/setup-dotnet@v4
with:
diff --git a/COMPILING.md b/COMPILING.md
index ec0a4b41a..2dd177c4f 100644
--- a/COMPILING.md
+++ b/COMPILING.md
@@ -1,9 +1,24 @@
## Windows
1) Open the solution in Visual Studio 2022
+ * Mesen uses the "Desktop Development With C++" workload and the ".NET Desktop Development" workload
+ * You do **not** need to install them in advance. If you don't have them, Visual Studio will install them when you open the project.
2) Compile as `Release`/`x64`
3) Set the startup project to the `UI` project and run
+### Testing
+
+There is a [pester](https://pester.dev/) powershell test script in `UI/tests` that will verify the prebuild steps for the `UI` project.
+
+
+1. [Install pester](https://pester.dev/docs/introduction/installation) if needed.
+1. Run from any powershell console:
+
+```cmd
+$ .\UI\tests\prebuild_windows.test.ps1
+```
+
+
## Linux
To build under Linux you need a version of Clang or GCC that supports C++17.
diff --git a/UI/UI.csproj b/UI/UI.csproj
index 9d5835a0f..9f00b5626 100644
--- a/UI/UI.csproj
+++ b/UI/UI.csproj
@@ -650,7 +650,9 @@
-
+
diff --git a/UI/prebuild_windows.ps1 b/UI/prebuild_windows.ps1
new file mode 100644
index 000000000..1678eb258
--- /dev/null
+++ b/UI/prebuild_windows.ps1
@@ -0,0 +1,128 @@
+# find and copy the files needed for building the UI project.
+# called by the build target in UI.csproj.
+# these steps are stored as a script for easier reading and editing.
+
+param (
+ [string]$OutDir, # $(ProjectDir)..\bin\$(RuntimeIdentifier)\$(Configuration), like bin\win-x64\Release
+ [string]$ProjectDir,
+ [string]$RuntimeIdentifier # win-x64, win-arm64, etc.
+)
+
+$ErrorActionPreference = "Stop"
+
+try {
+
+ $MissingParams = foreach ($Name in "ProjectDir", "OutDir", "RuntimeIdentifier") {
+ if ([string]::IsNullOrWhiteSpace((Get-Variable $Name -ValueOnly))) {
+ $Name
+ }
+ }
+
+ if ($MissingParams) {
+ $List = $MissingParams -join ", "
+ throw [System.ArgumentException] "Required parameter(s) '[$List]' are missing or empty."
+ }
+
+ # clean paths (PowerShell handles trailing slashes/dots automatically with Get-Item)
+ $ProjectDir = $ProjectDir.TrimEnd('\')
+ if ($OutDir -match '^[a-zA-Z]:') {
+ $FullOutDir = $OutDir
+ } else {
+ $FullOutDir = Join-Path "$ProjectDir" $OutDir
+ }
+
+ Write-Host "[PREBUILD] Project Dir: $ProjectDir"
+ Write-Host "[PREBUILD] Target Dir : $FullOutDir"
+
+ # ensure FullOutDir exists and move there
+ if (!(Test-Path $FullOutDir)) { New-Item -ItemType Directory -Path $FullOutDir | Out-Null }
+ if (!(Test-Path $FullOutDir)) {
+ throw [System.IO.DirectoryNotFoundException] "$FullOutDir does not exist"
+ }
+ Set-Location $FullOutDir
+
+ # set up Dependencies folder
+ $DepsFolder = Join-Path $FullOutDir "Dependencies"
+ if (Test-Path $DepsFolder) { Remove-Item -Recurse -Force $DepsFolder }
+ New-Item -ItemType Directory -Path $DepsFolder | Out-Null
+
+ # 1. copy external dlls managed by NuGet package manager.
+ # in web CI builds these are installed by 'dotnet restore'.
+ $Libs = @("libHarfBuzzSharp.dll", "libSkiaSharp.dll")
+ $NuGetBase = "$env:USERPROFILE\.nuget\packages"
+ $SpecificPathPart = "runtimes\$RuntimeIdentifier\native"
+
+ foreach ($Lib in $Libs) {
+ $FoundFile = Get-ChildItem -Path $NuGetBase -Filter $Lib -Recurse |
+ Where-Object { $_.FullName -like "*$SpecificPathPart*" } |
+ Select-Object -First 1
+
+ if ($FoundFile -and (Test-Path $FoundFile.FullName)) {
+ $LibSourcePath = $FoundFile.FullName
+ Write-Host "[PREBUILD] Copying NuGet dll $LibSourcePath"
+ Copy-Item $LibSourcePath -Destination $DepsFolder
+ } else {
+ $troubleshooting = "Please rebuild or restore packages. (Right-click solution in Visual Studio -> click 'Restore NuGet Packages', or run 'dotnet restore' in shell"
+
+ $SearchPattern = Join-Path $NuGetBase "*\$SpecificPathPart"
+ $DllSourceFolder = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($SearchPattern)
+
+ throw [System.IO.FileNotFoundException] "Required file '$Lib' for $RuntimeIdentifier not found in '$DllSourceFolder'. $troubleshooting"
+ }
+ }
+
+ # 2. copy MesenCore.dll output from building the Core c++ project
+ $Dll = "MesenCore.dll"
+ $DllSourcePath = Join-Path $FullOutDir $Dll
+
+ if (!(Test-Path $DllSourcePath)) {
+ Write-Host "[PREBUILD] $Dll not found in $DllSourcePath . Checking fallback path"
+ # Fallback: Check if the file is one level up or if the OutDir had a double-slash issue
+ $ParentDir = Split-Path $FullOutDir -Parent
+ $FoundFile = Get-ChildItem -Path $ParentDir -Filter $Dll -Recurse | Select-Object -First 1
+
+ if ($FoundFile) {
+ $DllSourcePath = $FoundFile.FullName
+ }
+ }
+
+ if ($DllSourcePath -and (Test-Path $DllSourcePath)) {
+ Write-Host "[PREBUILD] Copying $Dll from: $DllSourcePath"
+ Copy-Item $DllSourcePath -Destination $DepsFolder
+ } else {
+ $ParentPath = Split-Path $DllSourcePath -Parent
+ $DllSourceFolder = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ParentPath)
+ throw [System.IO.FileNotFoundException] "Required file '$Dll' not found in '$DllSourceFolder'. Please try rebuilding the solution."
+ }
+
+ # 3. copy files that exist in source control
+ Write-Host "[PREBUILD] Copying other dependencies from $ProjectDir\Dependencies"
+ Copy-Item -Path "$ProjectDir\Dependencies\*" -Destination $DepsFolder -Recurse -Force
+
+ # zip files
+ $ZipPath = Join-Path $FullOutDir "Dependencies.zip"
+ if (Test-Path $ZipPath) { Remove-Item $ZipPath }
+
+ Write-Host "[PREBUILD] Creating Zip $ZipPath..."
+ Compress-Archive -Path $DepsFolder -DestinationPath $ZipPath -Force
+
+ # move instead of copy so we don't leave copies of the zip lying around
+ Move-Item $ZipPath -Destination (Join-Path $ProjectDir "Dependencies.zip") -Force
+ Write-Host "[PREBUILD] Success."
+}
+catch {
+
+ # format an error message that shows the error inside Visual Studio.
+ # using this specific format turns this into a clickable link in the Error List window or tab.
+ # Format: path\to\file.ps1(line): error: message
+ # https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
+
+ $file = $_.InvocationInfo.ScriptName
+ $line = $_.InvocationInfo.ScriptLineNumber
+ $errorMessage = "$file($line): error: $($_.Exception.Message)"
+
+ # write to regular output so Visual Studio picks up + parses the message
+ Write-Host $errorMessage
+
+ exit 1
+}
diff --git a/UI/tests/prebuild_windows.test.ps1 b/UI/tests/prebuild_windows.test.ps1
new file mode 100644
index 000000000..f3f550be2
--- /dev/null
+++ b/UI/tests/prebuild_windows.test.ps1
@@ -0,0 +1,158 @@
+# test that all needed scenarios of the prebuild_windows script work.
+
+Describe "Mesen Prebuild Logic Verification" {
+ BeforeAll {
+ # set up sandbox in Temp
+ $Sandbox = Join-Path $env:TEMP "Mesen_CI_Sandbox"
+ if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force -ErrorAction SilentlyContinue }
+ New-Item -Path $Sandbox -ItemType Directory -Force | Out-Null
+
+ # map T: to this sandbox so Join-Path ..\.. works correctly
+ if (Get-PSDrive T -ErrorAction SilentlyContinue) { Remove-PSDrive T }
+ New-PSDrive -Name T -PSProvider FileSystem -Root $Sandbox | Out-Null
+
+ $script:OldProfile = $env:USERPROFILE
+ $env:USERPROFILE = "T:\Users\testuser"
+
+ $PREBUILD_SCRIPT = "$PSScriptRoot/../prebuild_windows.ps1"
+ $RUNTIME_ID = "win-x64"
+
+ $script:TestNumber = 0
+ }
+
+ AfterAll {
+ $env:USERPROFILE = $script:OldProfile
+ if (Get-PSDrive T -ErrorAction SilentlyContinue) { Remove-PSDrive T }
+ }
+
+ Context "Full Mock Isolation" {
+ BeforeEach {
+ # mock all actions that might cause changes on disk
+ Mock Compress-Archive { }
+ Mock Copy-Item { }
+ Mock Get-Item { param($Path) return [PSCustomObject]@{ FullName = $Path } }
+ Mock Move-Item { }
+ Mock New-Item { return [PSCustomObject]@{ FullName = "MockedDir" } }
+ Mock Remove-Item { }
+ Mock Set-Location { }
+ Mock Test-Path { return $true }
+
+ Mock Get-ChildItem {
+ param($Path, $Filter)
+ if ($Path -like "*\.nuget\*") {
+ return [PSCustomObject]@{ FullName = Join-Path $env:USERPROFILE ".nuget\packages\runtimes\$RUNTIME_ID\native\$Filter" }
+ }
+ if ($Filter -eq "MesenCore.dll") {
+ return [PSCustomObject]@{ FullName = Join-Path $env:USERPROFILE "code\mesen\bin\$RUNTIME_ID\Release\MesenCore.dll" }
+ }
+ }
+ $error.Clear()
+
+ $script:TestNumber += 1
+ Write-Host "-------"
+ Write-Host "[Test] $TestNumber" -ForegroundColor Cyan
+ }
+
+ It "Should handle Relative OutDir (Local Style)" {
+ $params = @{
+ ProjectDir = Join-Path $env:USERPROFILE "code\UI"
+ OutDir = "..\bin\Release"
+ RuntimeIdentifier = $RUNTIME_ID
+ }
+ { & $PREBUILD_SCRIPT @params } | Should -Not -Throw
+ }
+
+ It "Should handle Absolute OutDir (CI Style)" {
+ $params = @{
+ ProjectDir = Join-Path $env:USERPROFILE "code\UI"
+ OutDir = Join-Path $env:USERPROFILE "code\bin\Release"
+ RuntimeIdentifier = $RUNTIME_ID
+ }
+ { & $PREBUILD_SCRIPT @params } | Should -Not -Throw
+ }
+
+ It "Matches the vcxproj OutDir structure" {
+ $Repo = "T:\MesenRepo"
+ $ProjectDir = "$Repo\UI"
+
+ Mock Test-Path { return $true }
+ Mock Copy-Item { }
+ Mock Compress-Archive { }
+ Mock Move-Item { }
+ Mock New-Item { [PSCustomObject]@{ FullName = "MockedDir" } }
+ Mock Set-Location { }
+
+ $params = @{
+ ProjectDir = $ProjectDir
+ OutDir = "..\bin\$RUNTIME_ID\Release"
+ RuntimeIdentifier = $RUNTIME_ID
+ }
+
+ { & $PREBUILD_SCRIPT @params } | Should -Not -Throw
+ }
+
+ It "Should verify the zip ends up in the ProjectDir" {
+ $ProjectDir = Join-Path $env:USERPROFILE "code\mesen\UI"
+
+ $params = @{
+ ProjectDir = $ProjectDir
+ OutDir = "T:\Some\Other\Path"
+ RuntimeIdentifier = $RUNTIME_ID
+ }
+ { & $PREBUILD_SCRIPT @params } | Should -Not -Throw
+
+ $ExpectedDestination = Join-Path $ProjectDir "Dependencies.zip"
+
+ Assert-MockCalled Move-Item -ParameterFilter {
+ $Destination -eq $ExpectedDestination
+ }
+ }
+
+ It "Should raise exception on empty paths" {
+ $params = @{ ProjectDir = ""; OutDir = ""; RuntimeIdentifier = "win-x64" }
+
+ & $PREBUILD_SCRIPT @params 2>&1
+
+ $error.Count | Should -BeGreaterThan 0
+ $error[0].Exception.GetType().FullName | Should -Be "System.ArgumentException"
+ $LASTEXITCODE | Should -Be 1
+ }
+
+ It "Should raise exception when NuGet DLL is missing" {
+ Mock Get-ChildItem { return $null } -ParameterFilter { $Path -like "*\.nuget\*" }
+
+ $params = @{
+ ProjectDir = "T:\Mesen"
+ OutDir = "bin"
+ RuntimeIdentifier = $RUNTIME_ID
+ }
+
+ & $PREBUILD_SCRIPT @params 2>&1
+
+ $error.Count | Should -BeGreaterThan 0
+ $error[0].Exception.GetType().FullName | Should -Be "System.IO.FileNotFoundException"
+ $LASTEXITCODE | Should -Be 1
+ }
+
+ It "Should print error when MesenCore.dll is missing" {
+ Mock Test-Path {
+ param($Path)
+ if ($Path -like "*MesenCore.dll") { return $false }
+ return $true
+ }
+ Mock Get-ChildItem { return $null } -ParameterFilter { $Filter -eq "MesenCore.dll" }
+
+ $params = @{
+ ProjectDir = "T:\Mesen"
+ OutDir = "bin"
+ RuntimeIdentifier = $RUNTIME_ID
+ }
+
+ & $PREBUILD_SCRIPT @params 2>&1
+
+ $error.Count | Should -BeGreaterThan 0
+ $error[0].Exception.GetType().FullName | Should -Be "System.IO.FileNotFoundException"
+ $LASTEXITCODE | Should -Be 1
+ }
+ }
+}