diff --git a/.github/actions/set-display-resolution-vdd/action.yml b/.github/actions/set-display-resolution-vdd/action.yml
new file mode 100644
index 0000000000..f76d295076
--- /dev/null
+++ b/.github/actions/set-display-resolution-vdd/action.yml
@@ -0,0 +1,135 @@
+name: Set up display resolution (via MttVDD virtual display driver)
+description: >-
+ Install VirtualDrivers/Virtual-Display-Driver (IDD-based) silently via NefCon,
+ pre-write vdd_settings.xml so the driver spawns a single monitor at the
+ requested resolution, disable the Hyper-V Video adapter so MttVDD becomes the
+ primary display, and apply the requested per-monitor DPI scale via the shared
+ set-monitor-dpi sub-action. Use set-monitor-dpi directly later in the job to
+ switch DPI without re-installing VDD.
+
+inputs:
+ width:
+ description: Display width in pixels
+ required: false
+ default: '3840'
+ height:
+ description: Display height in pixels
+ required: false
+ default: '2160'
+ target-dpi:
+ description: >-
+ Initial per-monitor DPI scale percentage (100, 125, 150, 175, 200, 225,
+ 250, 300, …). Default 100. Reusable by calling ./.github/actions/set-monitor-dpi
+ to switch later in the same job.
+ required: false
+ default: '100'
+
+runs:
+ using: composite
+ steps:
+ # Settings must exist before the driver loads so the first device init reads our config.
+ - name: Pre-write vdd_settings.xml
+ shell: pwsh
+ env:
+ VDD_WIDTH: ${{ inputs.width }}
+ VDD_HEIGHT: ${{ inputs.height }}
+ run: |
+ $vddDir = "C:\VirtualDisplayDriver"
+ New-Item -ItemType Directory -Path $vddDir -Force | Out-Null
+
+ $xml = @"
+
+
+ 1
+ default
+
+ 60
+
+
+
+ $($env:VDD_WIDTH)
+ $($env:VDD_HEIGHT)
+ 60
+
+
+
+ "@
+ Set-Content -Path "$vddDir\vdd_settings.xml" -Value $xml -Encoding utf8
+ Get-Content "$vddDir\vdd_settings.xml"
+
+ # Adapted from VirtualDrivers/Virtual-Display-Driver Community Scripts/silent-install.ps1.
+ - name: Install Virtual Display Driver via NefCon
+ shell: pwsh
+ run: |
+ $tempDir = Join-Path $env:TEMP "VDDInstall"
+ New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
+
+ # NefCon (driver install tool — pnputil alternative that handles INF + Root devices)
+ $NefConURL = "https://github.com/nefarius/nefcon/releases/download/v1.14.0/nefcon_v1.14.0.zip"
+ $NefConZip = Join-Path $tempDir "nefcon.zip"
+ Write-Host "Downloading NefCon..."
+ Invoke-WebRequest -Uri $NefConURL -OutFile $NefConZip -UseBasicParsing
+ Expand-Archive -Path $NefConZip -DestinationPath $tempDir -Force
+ $NefConExe = Join-Path $tempDir "x64\nefconw.exe"
+
+ # VDD driver (signed via SignPath)
+ $DriverURL = "https://github.com/VirtualDrivers/Virtual-Display-Driver/releases/download/25.7.23/VirtualDisplayDriver-x86.Driver.Only.zip"
+ $driverZip = Join-Path $tempDir "driver.zip"
+ Write-Host "Downloading VDD driver..."
+ Invoke-WebRequest -Uri $DriverURL -OutFile $driverZip -UseBasicParsing
+ Expand-Archive -Path $driverZip -DestinationPath $tempDir -Force
+
+ # Trust the driver's signing certificate so Windows accepts the install without prompting.
+ $catFile = Join-Path $tempDir "VirtualDisplayDriver\mttvdd.cat"
+ $catBytes = [System.IO.File]::ReadAllBytes($catFile)
+ $certificates = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
+ $certificates.Import($catBytes)
+ $certsFolder = Join-Path $tempDir "ExportedCerts"
+ New-Item -ItemType Directory -Path $certsFolder -Force | Out-Null
+ foreach ($cert in $certificates) {
+ $certPath = Join-Path $certsFolder "$($cert.Thumbprint).cer"
+ [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
+ Import-Certificate -FilePath $certPath -CertStoreLocation "Cert:\LocalMachine\TrustedPublisher" | Out-Null
+ }
+
+ Write-Host "Installing VDD driver..."
+ Push-Location $tempDir
+ & $NefConExe install ".\VirtualDisplayDriver\MttVDD.inf" "Root\MttVDD"
+ Pop-Location
+ Start-Sleep -Seconds 15
+ Write-Host "After install:"
+ Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution
+ Get-PnpDevice -Class Monitor -ErrorAction SilentlyContinue | Format-Table FriendlyName, Status
+
+ # DPI before Hyper-V disable so VDD inherits the scale and we skip a re-scale event.
+ # No PnP cycle: the upcoming Hyper-V disable is itself a display-config-change event
+ # which causes Windows to read PerMonitorSettings.
+ - name: Apply initial per-monitor DPI scale
+ uses: ./.github/actions/set-monitor-dpi
+ with:
+ target-dpi: ${{ inputs.target-dpi }}
+ monitor-height: ${{ inputs.height }}
+ pnp-cycle: 'false'
+
+ # MttVDD spawned a monitor at the requested resolution from vdd_settings.xml. Disable the
+ # Hyper-V Video adapter via PnP so MttVDD is the only display and inherits primary.
+ - name: Disable Hyper-V Video adapter (leave MttVDD as the only display)
+ shell: pwsh
+ run: |
+ Write-Host "Display adapters before disable:"
+ Get-PnpDevice -Class Display | Format-Table FriendlyName, Status, InstanceId
+ $hyperv = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -like "*Hyper-V*" }
+ if ($hyperv) {
+ foreach ($dev in $hyperv) {
+ Write-Host "Disabling: $($dev.FriendlyName) ($($dev.InstanceId))"
+ Disable-PnpDevice -InstanceId $dev.InstanceId -Confirm:$false -ErrorAction Continue
+ }
+ Start-Sleep -Seconds 5
+ } else {
+ Write-Host "No Hyper-V Video adapter found — nothing to disable."
+ }
+ Write-Host "Display adapters after disable:"
+ Get-PnpDevice -Class Display | Format-Table FriendlyName, Status
+ Write-Host "Final display state:"
+ Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution
+ Get-PnpDevice -Class Monitor -ErrorAction SilentlyContinue | Format-Table FriendlyName, Status
diff --git a/.github/actions/set-display-resolution/action.yml b/.github/actions/set-display-resolution/action.yml
new file mode 100644
index 0000000000..c70b7ebdb6
--- /dev/null
+++ b/.github/actions/set-display-resolution/action.yml
@@ -0,0 +1,27 @@
+name: Set up display resolution
+description: >-
+ Resize the runner's primary display via the built-in Set-DisplayResolution
+ cmdlet. The Hyper-V Video adapter caps at 1920x1080; for higher resolutions
+ use set-display-resolution-vdd (IDD virtual display).
+
+inputs:
+ width:
+ description: Display width in pixels (max 1920 on stock GH runners)
+ required: false
+ default: '1920'
+ height:
+ description: Display height in pixels (max 1080 on stock GH runners)
+ required: false
+ default: '1080'
+
+runs:
+ using: composite
+ steps:
+ - name: Set display resolution to ${{ inputs.width }}x${{ inputs.height }}
+ shell: pwsh
+ run: |
+ Write-Host "Before:"
+ Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution
+ Set-DisplayResolution -Width ${{ inputs.width }} -Height ${{ inputs.height }} -Force
+ Write-Host "After:"
+ Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution
diff --git a/.github/actions/set-monitor-dpi/action.yml b/.github/actions/set-monitor-dpi/action.yml
new file mode 100644
index 0000000000..539b758271
--- /dev/null
+++ b/.github/actions/set-monitor-dpi/action.yml
@@ -0,0 +1,97 @@
+name: Set per-monitor DPI scale
+description: >-
+ Override the per-monitor DPI scaling for every display Windows knows about. Re-runnable —
+ call as many times as needed within a job to switch DPI between test sessions.
+
+inputs:
+ target-dpi:
+ description: >-
+ Target DPI scale percentage (100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500).
+ required: true
+ monitor-height:
+ description: >-
+ Physical height of the active monitor in pixels. Used to derive the scale Windows
+ considers "recommended" (Windows Server 2025 picks `recommended_pct = height/720*100`
+ snapped to the standard table for GH-hosted runners regardless of EDID).
+ required: true
+ pnp-cycle:
+ description: >-
+ PnP-cycle active displays after writing the registry. Required for Windows to re-read
+ PerMonitorSettings mid-session (registry-only change is otherwise cached). Set to
+ 'false' when this is the initial DPI setup right after a VDD install, since the
+ Hyper-V→VDD switch is already a display-config-change event that triggers the read.
+ required: false
+ default: 'true'
+
+runs:
+ using: composite
+ steps:
+ # Writes HKCU\Control Panel\Desktop\PerMonitorSettings\\DpiValue for each
+ # monitor enumerated under HKLM ScaleFactors. DpiValue is a signed-DWORD step offset from
+ # recommended; the standard scale table is { 100, 125, 150, 175, 200, 225, 250, 300, 350,
+ # 400, 450, 500 } percent. Applies live without session logoff via WM_SETTINGCHANGE.
+ - name: Apply per-monitor DPI scale (target ${{ inputs.target-dpi }}%)
+ shell: pwsh
+ env:
+ TARGET_DPI: ${{ inputs.target-dpi }}
+ MONITOR_HEIGHT: ${{ inputs.monitor-height }}
+ PNP_CYCLE: ${{ inputs.pnp-cycle }}
+ run: |
+ $scaleSteps = @(100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500)
+ function ClosestStepIdx($pct) {
+ $best = 0; $bestDist = [int]::MaxValue
+ for ($i = 0; $i -lt $scaleSteps.Count; $i++) {
+ $d = [Math]::Abs($scaleSteps[$i] - $pct)
+ if ($d -lt $bestDist) { $bestDist = $d; $best = $i }
+ }
+ return $best
+ }
+
+ $target = [int]$env:TARGET_DPI
+ $height = [int]$env:MONITOR_HEIGHT
+ $recommendedPct = $height / 720.0 * 100
+ $recommendedIdx = ClosestStepIdx $recommendedPct
+ $targetIdx = ClosestStepIdx $target
+ $offset = $targetIdx - $recommendedIdx
+ $offsetDword = if ($offset -lt 0) { 0x100000000 + $offset } else { [uint32]$offset }
+ Write-Host "monitor ${height}p → recommended ~$([int]$recommendedPct)% (idx $recommendedIdx); target $target% (idx $targetIdx); offset=$offset (DWORD 0x$('{0:X8}' -f $offsetDword))"
+
+ $pms = "HKCU:\Control Panel\Desktop\PerMonitorSettings"
+ if (-not (Test-Path $pms)) { New-Item -Path $pms -Force | Out-Null }
+ $sf = "HKLM:\System\CurrentControlSet\Control\GraphicsDrivers\ScaleFactors"
+ if (-not (Test-Path $sf)) {
+ Write-Host "HKLM ScaleFactors path not present (no monitors registered) — DPI override skipped."
+ return
+ }
+ foreach ($m in Get-ChildItem -Path $sf) {
+ $sub = Join-Path $pms $m.PSChildName
+ if (-not (Test-Path $sub)) { New-Item -Path $sub -Force | Out-Null }
+ Set-ItemProperty -Path $sub -Name "DpiValue" -Value $offsetDword -Type DWord -Force
+ Write-Host " $($m.PSChildName) → DpiValue=$offset"
+ }
+
+ # PowerShell here-string `'@` at column 0 conflicts with YAML block-scalar indentation —
+ # build the C# source as a string array joined with newlines instead.
+ $cs = @(
+ 'using System;',
+ 'using System.Runtime.InteropServices;',
+ 'public static class P {',
+ ' [DllImport("user32.dll")]',
+ ' public static extern int SendMessageTimeout(IntPtr h, uint m, IntPtr w, IntPtr l, uint f, uint t, out IntPtr r);',
+ '}'
+ ) -join "`n"
+ Add-Type -TypeDefinition $cs
+ $r = [IntPtr]::Zero
+ [P]::SendMessageTimeout([IntPtr]0xFFFF, 0x001A, [IntPtr]::Zero, [IntPtr]::Zero, 2, 5000, [ref]$r) | Out-Null
+ Start-Sleep -Seconds 2
+
+ # PnP-cycle active displays so Windows re-reads PerMonitorSettings (registry-only
+ # change is otherwise cached after the first display config change of the session).
+ if ($env:PNP_CYCLE -eq 'true') {
+ $active = Get-PnpDevice -Class Display | Where-Object { $_.Status -eq 'OK' }
+ foreach ($d in $active) { Disable-PnpDevice -InstanceId $d.InstanceId -Confirm:$false -ErrorAction Continue }
+ Start-Sleep -Seconds 2
+ foreach ($d in $active) { Enable-PnpDevice -InstanceId $d.InstanceId -Confirm:$false -ErrorAction Continue }
+ Start-Sleep -Seconds 5
+ Write-Host "PnP-cycled $($active.Count) display device(s)"
+ }
diff --git a/.github/workflows/test-windows-editor.yml b/.github/workflows/test-windows-editor.yml
new file mode 100644
index 0000000000..4d8df6873f
--- /dev/null
+++ b/.github/workflows/test-windows-editor.yml
@@ -0,0 +1,123 @@
+name: Test GameStudio (Editor Screenshots)
+
+on:
+ workflow_dispatch:
+ inputs:
+ build-type:
+ description: Build
+ default: Debug
+ type: choice
+ options:
+ - Debug
+ - Release
+ schedule:
+ # Daily at 05:17 UTC — offset off top-of-hour to avoid GitHub Actions cron load spikes;
+ # gap from test-samples-screenshots (04:37) so they don't compete for runners.
+ - cron: '17 5 * * *'
+
+concurrency:
+ group: test-windows-editor-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ Run:
+ name: Editor screenshots (${{ github.event.inputs.build-type || 'Debug' }})
+ runs-on: windows-2025-vs2026
+ env:
+ DOTNET_DbgEnableMiniDump: "1"
+ DOTNET_DbgMiniDumpType: "1"
+ DOTNET_DbgMiniDumpName: "${{ github.workspace }}\\crash-dumps\\dotnet_%p.dmp"
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ lfs: true
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Configure crash dumps
+ shell: pwsh
+ run: |
+ reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting" /v DontShowUI /t REG_DWORD /d 1 /f
+ $dumpDir = "${{ github.workspace }}\crash-dumps"
+ New-Item -Path $dumpDir -ItemType Directory -Force | Out-Null
+ reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpFolder /t REG_EXPAND_SZ /d $dumpDir /f
+ reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpType /t REG_DWORD /d 1 /f
+ reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpCount /t REG_DWORD /d 10 /f
+
+ # nuget.config's `stride-local` source points at bin/packages, which doesn't exist on a
+ # fresh checkout (auto-pack-deploy populates it on first build); pre-create it empty.
+ - name: Pre-create bin/packages so restore doesn't fail on missing local source
+ shell: pwsh
+ run: New-Item -ItemType Directory -Path bin/packages -Force | Out-Null
+
+ # PackageStore.LoadDefaultSettings(null) only reads the user + machine NuGet configs,
+ # not the workspace nuget.config — register bin/packages in the user config.
+ - name: Register bin/packages with user NuGet config
+ shell: pwsh
+ run: |
+ New-Item -Path "$env:APPDATA\NuGet" -ItemType Directory -Force | Out-Null
+ dotnet nuget add source "${{ github.workspace }}\bin\packages" --name stride-local --configfile "$env:APPDATA\NuGet\NuGet.Config"
+
+ # GH runners default to a 0x0 fallback display; WPF clamps the editor to that. The
+ # composite action installs an IDD virtual display so we get a real desktop surface.
+ - name: Set display resolution
+ uses: ./.github/actions/set-display-resolution-vdd
+ with:
+ width: '3840'
+ height: '2160'
+
+ - name: Build Stride.GameStudio.AutoTesting
+ run: |
+ dotnet build sources\editor\Stride.GameStudio.AutoTesting\Stride.GameStudio.AutoTesting.csproj `
+ -nr:false -v:m -p:WarningLevel=0 `
+ -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }}
+
+ - name: Build Stride.Editor.Tests
+ run: |
+ dotnet build tests\editor\Stride.Editor.Tests.csproj `
+ -nr:false -v:m -p:WarningLevel=0 `
+ -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }}
+
+ # EditorScreenshotTests.Capture is an xunit [Theory] over Fixtures(); each entry spawns
+ # the AutoTesting CLI as a subprocess (per-fixture WPF singleton-state isolation),
+ # snapshots the runner output into /ui-test-out-dpi//, then runs
+ # ScreenshotComparator.Compare against tests/editor/baselines/dpi/. ANTHROPIC_API_KEY
+ # opts the comparator into Claude vision second-opinions on frames that exceed LPIPS —
+ # cost is bounded because Claude only fires on already-failing frames.
+ - name: Run editor screenshot tests
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ run: |
+ dotnet test tests\editor\Stride.Editor.Tests.csproj `
+ --no-build `
+ --filter "FullyQualifiedName~Stride.Editor.Tests.EditorScreenshotTests" `
+ --logger "trx;LogFileName=editor-screenshots.trx" `
+ --results-directory TestResults `
+ -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }}
+
+ - name: Publish test report
+ if: always()
+ uses: phoenix-actions/test-reporting@v15
+ with:
+ name: 'Editor screenshot regression'
+ path: TestResults/*.trx
+ reporter: dotnet-trx
+ output-to: step-summary
+
+ - name: Upload screenshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: editor-screenshots
+ path: ui-test-out-dpi*/
+ if-no-files-found: warn
+
+ - name: Upload crash dumps
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: editor-crash-dumps
+ path: crash-dumps/
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 0e2b546ccf..74957e514d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,6 +99,7 @@ screenshots
samplesGenerated
screenshot-regression-out
screenshot-out
+ui-test-out-dpi*
# Auto-generated by samples/Directory.Build.targets when -p:StrideAutoTesting=true is passed
# (force-loads Stride.Games.AutoTesting at startup so its [ModuleInitializer] runs).
_AutoTestingBootstrap.g.cs
diff --git a/build/Stride.sln b/build/Stride.sln
index ef0a3f6140..5bb844ee81 100644
--- a/build/Stride.sln
+++ b/build/Stride.sln
@@ -146,6 +146,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Editor", "..\sources
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.GameStudio.Tests", "..\sources\editor\Stride.GameStudio.Tests\Stride.GameStudio.Tests.csproj", "{0EA748AF-E1DC-4788-BA50-8BABD56F220C}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.GameStudio.AutoTesting", "..\sources\editor\Stride.GameStudio.AutoTesting\Stride.GameStudio.AutoTesting.csproj", "{9B07728D-FF67-493D-939C-87CE6B788A89}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Editor.Tests", "..\tests\editor\Stride.Editor.Tests.csproj", "{34A6666F-EFF5-4979-973E-91C4185EE27B}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Design", "..\sources\core\Stride.Core.Design\Stride.Core.Design.csproj", "{66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Graphics.Regression", "..\sources\engine\Stride.Graphics.Regression\Stride.Graphics.Regression.csproj", "{D002FEB1-00A6-4AB1-A83F-1F253465E64D}"
@@ -349,6 +353,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Stride.Build.Sdk.Tests", "S
..\sources\sdk\Stride.Build.Sdk.Tests\Sdk\Sdk.targets = ..\sources\sdk\Stride.Build.Sdk.Tests\Sdk\Sdk.targets
EndProjectSection
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.ScreenshotComparator", "..\sources\tests\Stride.ScreenshotComparator\Stride.ScreenshotComparator.csproj", "{74D99A2C-7F6E-473E-8839-8229F475AA5A}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -774,6 +780,26 @@ Global
{0EA748AF-E1DC-4788-BA50-8BABD56F220C}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
{0EA748AF-E1DC-4788-BA50-8BABD56F220C}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{0EA748AF-E1DC-4788-BA50-8BABD56F220C}.Release|Win32.ActiveCfg = Release|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Win32.ActiveCfg = Debug|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Win32.ActiveCfg = Release|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Win32.ActiveCfg = Debug|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Win32.ActiveCfg = Release|Any CPU
{66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -1502,6 +1528,18 @@ Global
{35EC42D8-0A09-41AE-A918-B8C2796061B3}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{35EC42D8-0A09-41AE-A918-B8C2796061B3}.Release|Win32.ActiveCfg = Release|Any CPU
{35EC42D8-0A09-41AE-A918-B8C2796061B3}.Release|Win32.Build.0 = Release|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Win32.ActiveCfg = Debug|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Win32.Build.0 = Debug|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Win32.ActiveCfg = Release|Any CPU
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Win32.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1553,6 +1591,8 @@ Global
{E7B1B17F-D04B-4978-B504-A6BB3EE846C9} = {A7ED9F01-7D78-4381-90A6-D50E51C17250}
{16E02D45-5530-4617-97DC-BC3BDF77DE2C} = {5D2D3BE8-9910-45CA-8E45-95660DA4C563}
{0EA748AF-E1DC-4788-BA50-8BABD56F220C} = {F5F744B5-803E-4180-B82A-8B1F0BCD6579}
+ {9B07728D-FF67-493D-939C-87CE6B788A89} = {F5F744B5-803E-4180-B82A-8B1F0BCD6579}
+ {34A6666F-EFF5-4979-973E-91C4185EE27B} = {F5F744B5-803E-4180-B82A-8B1F0BCD6579}
{66581DAD-70AD-4475-AE47-C6C0DF1EC5E2} = {25F10A0B-7259-404C-86BE-FD2363C92F72}
{D002FEB1-00A6-4AB1-A83F-1F253465E64D} = {A7ED9F01-7D78-4381-90A6-D50E51C17250}
{942A5B1D-2B3D-4B30-98DE-336CE93F4F12} = {860946E4-CC77-4FDA-A4FD-3DB2A502A696}
@@ -1629,6 +1669,7 @@ Global
{D26186F8-7158-4A01-9524-EF4F53E0802C} = {0B81090E-4066-4723-A658-8AEDBEADE619}
{8D873BE7-8EF2-4478-B86A-249021D046EB} = {0B81090E-4066-4723-A658-8AEDBEADE619}
{E6B11A34-A1DB-41C2-B509-94DACA9D9BDE} = {0B81090E-4066-4723-A658-8AEDBEADE619}
+ {74D99A2C-7F6E-473E-8839-8229F475AA5A} = {1AE1AC60-5D2F-4CA7-AE20-888F44551185}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FF877973-604D-4EA7-B5F5-A129961F9EF2}
@@ -1644,8 +1685,6 @@ Global
..\sources\shared\Stride.Core.ShellHelper\Stride.Core.ShellHelper.projitems*{3a3cb33c-64d9-4948-86c1-0d86320d23c3}*SharedItemsImports = 13
..\sources\shared\Stride.NuGetResolver.Targets\Stride.NuGetResolver.Targets.projitems*{50d1a3bb-4b41-4ef5-8d2f-3618a3b6c698}*SharedItemsImports = 5
..\sources\editor\Stride.Core.MostRecentlyUsedFiles\Stride.Core.MostRecentlyUsedFiles.projitems*{5863574d-7a55-49bc-8e65-babb74d8e66e}*SharedItemsImports = 5
- ..\sources\shared\Stride.Core.ShellHelper\Stride.Core.ShellHelper.projitems*{75d71310-ecf7-4592-9e35-3fe540040982}*SharedItemsImports = 5
- ..\sources\shared\Stride.NuGetResolver.Targets\Stride.NuGetResolver.Targets.projitems*{75d71310-ecf7-4592-9e35-3fe540040982}*SharedItemsImports = 5
..\sources\shared\Stride.Core.ShellHelper\Stride.Core.ShellHelper.projitems*{77e2fcc0-4ca6-436c-be6f-9418cb807d45}*SharedItemsImports = 5
..\sources\shared\Stride.NuGetResolver.Targets\Stride.NuGetResolver.Targets.projitems*{77e2fcc0-4ca6-436c-be6f-9418cb807d45}*SharedItemsImports = 5
..\sources\engine\Stride.Shared\Refactor\Stride.Refactor.projitems*{7af4b563-aad3-42ff-b91e-84b9d34d904a}*SharedItemsImports = 5
diff --git a/samples/Tests/SampleScreenshotTests.cs b/samples/Tests/SampleScreenshotTests.cs
index 2432dc7340..53574c5c90 100644
--- a/samples/Tests/SampleScreenshotTests.cs
+++ b/samples/Tests/SampleScreenshotTests.cs
@@ -5,8 +5,8 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using Stride.SampleScreenshotComparator;
using Stride.SampleScreenshotRunner;
+using Stride.Tests.ScreenshotComparator;
using Xunit;
using Xunit.Abstractions;
@@ -67,17 +67,18 @@ public void Sample(string name)
// Compare: in-proc LPIPS against committed baselines. --sample isolates this test from
// earlier theory entries that may have left captures in the same captureRoot.
- var modelPath = Path.Combine(worktree, "samples", "Tests", "Comparator", "models", "lpips_alex.onnx");
+ // ScreenshotComparator defaults to /models/lpips_alex.onnx, which the shared
+ // Stride.ScreenshotComparator project copies into our output via ProjectReference.
var baselineDir = Path.Combine(worktree, "tests", "Stride.Samples.Tests");
- var results = ScreenshotComparator.Compare(captureRoot, baselineDir, sampleFilter: name, modelPath: modelPath);
+ var results = ScreenshotComparator.Compare(captureRoot, baselineDir, sampleFilter: name);
foreach (var r in results)
{
var d = r.Lpips.HasValue ? $"lpips={r.Lpips.Value:F4} thr={r.Threshold:F2}" : "";
output.WriteLine($"[compare] {r.Status,-7} {r.Frame,-20} {d}{(r.Detail is null ? "" : " " + r.Detail)}");
}
- var drift = results.Where(r => r.Status is "drift" or "error").ToList();
- Assert.Empty(drift);
+ var failures = results.Where(r => r.Status is "drift" or "error" or "new").ToList();
+ Assert.Empty(failures);
// Test passed — wipe the regenerated sample dir to keep working trees small. Skip on
// failure so the post-mortem still has the regenerated project for local debugging.
diff --git a/samples/Tests/Stride.Samples.Tests.csproj b/samples/Tests/Stride.Samples.Tests.csproj
index 01b2e39c30..8a4e39c69b 100644
--- a/samples/Tests/Stride.Samples.Tests.csproj
+++ b/samples/Tests/Stride.Samples.Tests.csproj
@@ -33,16 +33,10 @@
+
-
-
-
-
-
-
-
diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props
index 595a80c42d..4f24ff4b1d 100644
--- a/sources/Directory.Packages.props
+++ b/sources/Directory.Packages.props
@@ -11,6 +11,7 @@
+
@@ -24,6 +25,7 @@
+
diff --git a/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs b/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs
index 6a0e43af43..4e99658ace 100644
--- a/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs
+++ b/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs
@@ -40,7 +40,14 @@ namespace Stride.Assets.Presentation.AssetEditors.GameEditor.Services
public delegate TEditorGame EditorGameFactory(TaskCompletionSource gameContentLoadedTaskSource, IEffectCompiler effectCompiler, string effectLogPath)
where TEditorGame : EditorServiceGame;
- public abstract partial class EditorGameController : IEditorGameController
+ /// Non-generic internal hook so external assemblies (test harness) can reach the
+ /// underlying without depending on the closed generic type.
+ internal interface IEditorGameAccess
+ {
+ EditorServiceGame EditorGame { get; }
+ }
+
+ public abstract partial class EditorGameController : IEditorGameController, IEditorGameAccess
where TEditorGame : EditorServiceGame
{
///
@@ -51,6 +58,8 @@ public abstract partial class EditorGameController : IEditorGameCon
/// Gets the game associated with the scene editor.
///
protected readonly TEditorGame Game;
+
+ EditorServiceGame IEditorGameAccess.EditorGame => Game;
///
/// The scene game thread.
///
diff --git a/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs b/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs
index e69801cead..0c1fdd0837 100644
--- a/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs
+++ b/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs
@@ -1,8 +1,11 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Windows;
+[assembly: InternalsVisibleTo("Stride.GameStudio.AutoTesting")]
+
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
diff --git a/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs b/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs
index 20469997f5..ddca9c2926 100644
--- a/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs
+++ b/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs
@@ -44,6 +44,15 @@ public AssetBuilderService([NotNull] string buildDirectory)
public event EventHandler AssetBuilt;
+ ///
+ /// The number of build units waiting in the queue to be picked up. Test harnesses use this
+ /// to detect a quiescent state; production code should treat it as informational only.
+ ///
+ public int QueuedBuildUnitCount
+ {
+ get { lock (queueLock) { return queue.Count; } }
+ }
+
public virtual void Dispose()
{
builder.Dispose();
diff --git a/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs b/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs
index 2f910cb206..7f5d906196 100644
--- a/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs
+++ b/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs
@@ -82,6 +82,12 @@ public GameStudioBuilderService(SessionViewModel sessionViewModel, GameSettingsP
///
public bool IsDisposed { get; private set; }
+ ///
+ /// The number of shader-compile tasks waiting in the queue. Test harnesses use this to
+ /// detect a quiescent state; production code should treat it as informational only.
+ ///
+ public int PendingShaderCompilationCount => taskScheduler.QueuedTaskCount;
+
public override void Dispose()
{
base.Dispose();
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/DpiUtil.cs b/sources/editor/Stride.GameStudio.AutoTesting/DpiUtil.cs
new file mode 100644
index 0000000000..d88f299b19
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/DpiUtil.cs
@@ -0,0 +1,45 @@
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
+// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
+
+using System.Runtime.InteropServices;
+
+namespace Stride.GameStudio.AutoTesting;
+
+/// Helpers shared between the AutoTesting runner and the xunit orchestrator.
+public static class DpiUtil
+{
+ [DllImport("user32.dll")]
+ private static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
+ [DllImport("Shcore.dll")]
+ private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
+ [DllImport("user32.dll")]
+ private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT { public int X; public int Y; }
+
+ private static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = (IntPtr)(-4);
+
+ ///
+ /// Returns the primary monitor's effective DPI scale as an integer percentage (96 → 100,
+ /// 144 → 150, 192 → 200, …). Switches the calling thread to PerMonitorAwareV2 first because
+ /// GetDpiForMonitor(MDT_EFFECTIVE_DPI) returns 96 in DPI-unaware processes regardless
+ /// of actual scaling.
+ ///
+ public static int DetectDpiPercent()
+ {
+ var prevContext = IntPtr.Zero;
+ try
+ {
+ prevContext = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
+ var hMon = MonitorFromPoint(default, 1 /* MONITOR_DEFAULTTOPRIMARY */);
+ if (GetDpiForMonitor(hMon, 0 /* MDT_EFFECTIVE_DPI */, out var dpi, out _) == 0)
+ return (int)Math.Round(dpi / 96.0 * 100);
+ }
+ catch { /* fall back to 100 */ }
+ finally
+ {
+ if (prevContext != IntPtr.Zero) SetThreadDpiAwarenessContext(prevContext);
+ }
+ return 100;
+ }
+}
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs b/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs
new file mode 100644
index 0000000000..bb30fd3602
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs
@@ -0,0 +1,432 @@
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
+// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
+using System;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Silk.NET.Core.Native;
+using Silk.NET.Direct3D11;
+using Silk.NET.DXGI;
+using Windows.Foundation.Metadata;
+using Windows.Graphics.Capture;
+using WinRT;
+
+// Disambiguate from Silk.NET.* counterparts.
+using IDirect3DDevice = Windows.Graphics.DirectX.Direct3D11.IDirect3DDevice;
+using DirectXPixelFormat = Windows.Graphics.DirectX.DirectXPixelFormat;
+
+namespace Stride.GameStudio.AutoTesting;
+
+///
+/// Captures a top-level window's pixel content via Windows.Graphics.Capture (WGC). WGC reads from
+/// the DWM compositor's output, so the source can be drawn by any graphics API — D3D11/12, Vulkan,
+/// GDI — and DComp content (WPF chrome, AvalonDock panels) is captured correctly. The yellow capture
+/// border and cursor are disabled where the OS supports it (Win11 22H2 / Win10 1903+).
+///
+internal static class GraphicsCaptureClient
+{
+ private const string GraphicsCaptureSessionType = "Windows.Graphics.Capture.GraphicsCaptureSession";
+
+ [ComImport, System.Runtime.InteropServices.Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IGraphicsCaptureItemInterop
+ {
+ [PreserveSig] int CreateForWindow(IntPtr window, ref System.Guid iid, out IntPtr value);
+ [PreserveSig] int CreateForMonitor(IntPtr monitor, ref System.Guid iid, out IntPtr value);
+ }
+
+ [ComImport, System.Runtime.InteropServices.Guid("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IDirect3DDxgiInterfaceAccess
+ {
+ [PreserveSig] int GetInterface(ref System.Guid iid, out IntPtr ptr);
+ }
+
+ // IID of IGraphicsCaptureItem; pinned because typeof(GraphicsCaptureItem).GUID is projection-
+ // version dependent.
+ private static readonly System.Guid IID_IGraphicsCaptureItem = new("79C3F95B-31F7-4EC2-A464-632EF5D30760");
+
+ [DllImport("d3d11.dll", ExactSpelling = true)]
+ private static extern int CreateDirect3D11DeviceFromDXGIDevice(IntPtr dxgiDevice, out IntPtr graphicsDevice);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RECT { public int Left, Top, Right, Bottom; }
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
+ private static extern bool GetClientRect(IntPtr hwnd, out RECT lpRect);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
+ private static extern bool SetForegroundWindow(IntPtr hwnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
+ private static extern bool RedrawWindow(IntPtr hwnd, IntPtr lprcUpdate, IntPtr hrgnUpdate, uint flags);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
+ private static extern bool ShowWindow(IntPtr hwnd, int nCmdShow);
+
+ private const int SW_RESTORE = 9;
+ private const uint RDW_INVALIDATE = 0x0001;
+ private const uint RDW_ALLCHILDREN = 0x0080;
+ private const uint RDW_UPDATENOW = 0x0100;
+ private const int GWL_STYLE = -16;
+ private const int GWL_EXSTYLE = -20;
+ private const long WS_EX_NOREDIRECTIONBITMAP = 0x00200000L;
+ private const int WDA_EXCLUDEFROMCAPTURE = 0x00000011;
+
+ [DllImport("user32.dll")]
+ private static extern long GetWindowLongPtrW(IntPtr hwnd, int nIndex);
+
+ [DllImport("user32.dll")]
+ private static extern int GetWindowDisplayAffinity(IntPtr hwnd, out uint dwAffinity);
+
+ private const int GWLP_HWNDPARENT = -8;
+ private const int E_INVALIDARG = unchecked((int)0x80070057);
+ private const long WS_EX_APPWINDOW = 0x00040000L;
+
+ [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
+ private static extern long SetWindowLongPtrW(IntPtr hwnd, int nIndex, long dwNewLong);
+
+ [DllImport("dwmapi.dll", PreserveSig = false)]
+ private static extern void DwmFlush();
+
+ private static readonly string DiagLogPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "gs-diag.log");
+ private static void DiagLog(string message)
+ {
+ try { System.IO.File.AppendAllText(DiagLogPath, $"{DateTime.UtcNow:HH:mm:ss.fff} [tid={System.Threading.Thread.CurrentThread.ManagedThreadId}] WGC: {message}\n"); }
+ catch { }
+ }
+
+ [DllImport("combase.dll", PreserveSig = false)]
+ private static extern void RoGetActivationFactory(IntPtr classId, in System.Guid iid, out IntPtr factory);
+
+ [DllImport("combase.dll", PreserveSig = false)]
+ private static extern void WindowsCreateString([MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string sourceString, uint length, out IntPtr hstring);
+
+ [DllImport("combase.dll")]
+ private static extern int WindowsDeleteString(IntPtr hstring);
+
+ private static T GetActivationFactory(string runtimeClassName) where T : class
+ {
+ WindowsCreateString(runtimeClassName, (uint)runtimeClassName.Length, out var hstring);
+ try
+ {
+ var iid = typeof(T).GUID;
+ RoGetActivationFactory(hstring, in iid, out var factoryPtr);
+ try { return (T)Marshal.GetObjectForIUnknown(factoryPtr); }
+ finally { Marshal.Release(factoryPtr); }
+ }
+ finally { WindowsDeleteString(hstring); }
+ }
+
+ /// Resources held open while a WGC capture session is active. Disposed via .
+ private sealed unsafe class CaptureSessionHandle
+ {
+ public GraphicsCaptureSession Session;
+ public Direct3D11CaptureFramePool FramePool;
+ public IntPtr DeviceAbi;
+ public IntPtr ContextAbi;
+ public IntPtr Hwnd;
+ public bool Promoted;
+ public long OrigExStyle;
+ public long OrigOwner;
+ }
+
+ ///
+ /// Creates a GraphicsCaptureItem from the HWND (with WS_EX_APPWINDOW promotion fallback for
+ /// owned/transient windows), spins up a D3D11 device + framepool + session, and returns a
+ /// handle. The caller must attach its FrameArrived handler before calling
+ /// , then dispose via .
+ ///
+ private static unsafe CaptureSessionHandle OpenSession(IntPtr hwnd)
+ {
+ if (hwnd == IntPtr.Zero) throw new ArgumentException("HWND is zero.", nameof(hwnd));
+
+ // 1. HWND → GraphicsCaptureItem. WGC rejects owned/transient/no-AppWindow windows (e.g.
+ // AvalonDock floating panels) with E_INVALIDARG — promote temporarily by clearing owner
+ // and adding WS_EX_APPWINDOW; restore in CloseSession.
+ var itemFactory = GetActivationFactory("Windows.Graphics.Capture.GraphicsCaptureItem");
+ var itemIid = IID_IGraphicsCaptureItem;
+ var createHr = itemFactory.CreateForWindow(hwnd, ref itemIid, out var itemAbi);
+ long origExStyle = 0, origOwner = 0;
+ var promoted = false;
+ if (createHr == E_INVALIDARG)
+ {
+ origExStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
+ origOwner = GetWindowLongPtrW(hwnd, GWLP_HWNDPARENT);
+ SetWindowLongPtrW(hwnd, GWL_EXSTYLE, origExStyle | WS_EX_APPWINDOW);
+ SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, 0);
+ promoted = true;
+ DiagLog($"WGC E_INVALIDARG; promoted hwnd 0x{hwnd.ToInt64():X} (cleared owner 0x{origOwner:X}, added WS_EX_APPWINDOW)");
+ createHr = itemFactory.CreateForWindow(hwnd, ref itemIid, out itemAbi);
+ if (createHr < 0) DiagLog($"CreateForWindow (after promotion) hr=0x{createHr:X8}");
+ }
+ if (createHr < 0)
+ {
+ if (promoted)
+ {
+ SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, origOwner);
+ SetWindowLongPtrW(hwnd, GWL_EXSTYLE, origExStyle);
+ }
+ Marshal.ThrowExceptionForHR(createHr);
+ }
+ GraphicsCaptureItem item;
+ try { item = MarshalInterface.FromAbi(itemAbi); }
+ finally { Marshal.Release(itemAbi); }
+
+ // 2. D3D11 device (BGRA support required for WGC framepool).
+ var d3d11 = D3D11.GetApi(null);
+ ID3D11Device* devicePtr = null;
+ ID3D11DeviceContext* contextPtr = null;
+ D3DFeatureLevel level = 0;
+ HResult hr = d3d11.CreateDevice(
+ pAdapter: null, DriverType: D3DDriverType.Hardware, Software: IntPtr.Zero,
+ Flags: (uint)CreateDeviceFlag.BgraSupport,
+ pFeatureLevels: null, FeatureLevels: 0,
+ SDKVersion: D3D11.SdkVersion,
+ ppDevice: ref devicePtr, pFeatureLevel: &level, ppImmediateContext: ref contextPtr);
+ if (hr.IsFailure) throw Marshal.GetExceptionForHR(hr.Value)!;
+
+ // 3. ID3D11Device → IDXGIDevice → IDirect3DDevice (WinRT).
+ IDirect3DDevice graphicsDevice;
+ IDXGIDevice* dxgiDevice = null;
+ var dxgiIid = IDXGIDevice.Guid;
+ SilkMarshal.ThrowHResult(devicePtr->QueryInterface(ref dxgiIid, (void**)&dxgiDevice));
+ try
+ {
+ Marshal.ThrowExceptionForHR(CreateDirect3D11DeviceFromDXGIDevice((IntPtr)dxgiDevice, out var graphicsDeviceUnk));
+ try { graphicsDevice = MarshalInspectable.FromAbi(graphicsDeviceUnk); }
+ finally { Marshal.Release(graphicsDeviceUnk); }
+ }
+ finally { dxgiDevice->Release(); }
+
+ // 4. Framepool + session. CreateFreeThreaded dispatches FrameArrived on a threadpool thread.
+ var size = item.Size;
+ DiagLog($"item.Size={size.Width}x{size.Height} hwnd=0x{hwnd.ToInt64():X}");
+ if (size.Width <= 0 || size.Height <= 0)
+ {
+ if (GetClientRect(hwnd, out var rect))
+ {
+ size.Width = rect.Right - rect.Left;
+ size.Height = rect.Bottom - rect.Top;
+ DiagLog($"WGC: fell back to GetClientRect → {size.Width}x{size.Height}");
+ }
+ if (size.Width <= 0 || size.Height <= 0)
+ throw new InvalidOperationException("GraphicsCaptureItem reports zero size and GetClientRect failed; window not yet realised.");
+ }
+ var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
+ graphicsDevice,
+ DirectXPixelFormat.B8G8R8A8UIntNormalized,
+ numberOfBuffers: 1,
+ size: size);
+ var session = framePool.CreateCaptureSession(item);
+
+ // Suppress yellow capture border (Win11 22H2+) and cursor overlay (Win10 1903+).
+ if (ApiInformation.IsPropertyPresent(GraphicsCaptureSessionType, "IsBorderRequired"))
+ session.IsBorderRequired = false;
+ if (ApiInformation.IsPropertyPresent(GraphicsCaptureSessionType, "IsCursorCaptureEnabled"))
+ session.IsCursorCaptureEnabled = false;
+
+ // Diagnostics: WS_EX_NOREDIRECTIONBITMAP excludes from DWM redirection (and so from WGC);
+ // WDA_EXCLUDEFROMCAPTURE opts out of capture entirely.
+ var exStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
+ GetWindowDisplayAffinity(hwnd, out var affinity);
+ DiagLog($"Styles: exStyle=0x{exStyle:X} NoRedirBitmap={(exStyle & WS_EX_NOREDIRECTIONBITMAP) != 0} affinity=0x{affinity:X} ExcludeFromCapture={affinity == WDA_EXCLUDEFROMCAPTURE}");
+
+ // With numberOfBuffers=1, any frame produced before the FrameArrived handler is attached
+ // cycles through the pool without firing the event. Caller attaches its handler then
+ // calls StartCaptureAndNudge.
+ return new CaptureSessionHandle
+ {
+ Session = session,
+ FramePool = framePool,
+ DeviceAbi = (IntPtr)devicePtr,
+ ContextAbi = (IntPtr)contextPtr,
+ Hwnd = hwnd,
+ Promoted = promoted,
+ OrigExStyle = origExStyle,
+ OrigOwner = origOwner,
+ };
+ }
+
+ ///
+ /// Starts a capture session opened via after the caller has attached
+ /// its FrameArrived handler. Nudges the window so DWM produces composition.
+ ///
+ private static void StartCaptureAndNudge(CaptureSessionHandle handle)
+ {
+ // WGC only delivers FrameArrived when DWM presents new composition; nudge the window.
+ var fg = SetForegroundWindow(handle.Hwnd);
+ var sw = ShowWindow(handle.Hwnd, SW_RESTORE);
+ var rd = RedrawWindow(handle.Hwnd, IntPtr.Zero, IntPtr.Zero, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
+ DiagLog($"Nudge: SetForegroundWindow={fg} ShowWindow={sw} RedrawWindow={rd}");
+
+ handle.Session.StartCapture();
+ DiagLog("StartCapture returned");
+
+ try { DwmFlush(); }
+ catch (Exception ex) { DiagLog($"DwmFlush threw: {ex.Message}"); }
+ }
+
+ private static void CloseSession(CaptureSessionHandle h)
+ {
+ h.Session?.Dispose();
+ h.FramePool?.Dispose();
+ ReleasePointer(h.ContextAbi);
+ ReleasePointer(h.DeviceAbi);
+ if (h.Promoted)
+ {
+ SetWindowLongPtrW(h.Hwnd, GWLP_HWNDPARENT, h.OrigOwner);
+ SetWindowLongPtrW(h.Hwnd, GWL_EXSTYLE, h.OrigExStyle);
+ }
+ }
+
+ public static Task CaptureToPngAsync(IntPtr hwnd, string path)
+ {
+ var handle = OpenSession(hwnd);
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var framePool = handle.FramePool;
+ int frameCallbackCount = 0;
+ Windows.Foundation.TypedEventHandler handler = null!;
+ handler = (sender, _) =>
+ {
+ var n = System.Threading.Interlocked.Increment(ref frameCallbackCount);
+ var frame = sender.TryGetNextFrame();
+ DiagLog($"FrameArrived #{n} frame={(frame is null ? "null" : $"{frame.ContentSize.Width}x{frame.ContentSize.Height}")}");
+ if (frame is null) return;
+ framePool.FrameArrived -= handler;
+ tcs.TrySetResult(frame);
+ };
+ framePool.FrameArrived += handler;
+ StartCaptureAndNudge(handle);
+ return WaitAndEncodeAsync(tcs.Task, handle, path);
+ }
+
+ ///
+ /// Returns when at least FrameArrived events have fired AND
+ /// have elapsed since the first.
+ ///
+ public static async Task WaitForFramesAsync(IntPtr hwnd, int minFrames, double postFirstFrameDelaySeconds, double timeoutSeconds)
+ {
+ var handle = OpenSession(hwnd);
+ var framePool = handle.FramePool;
+ var firstFrameAt = DateTime.MinValue;
+ var count = 0;
+ var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Windows.Foundation.TypedEventHandler handler = null!;
+ handler = (sender, _) =>
+ {
+ using var f = sender.TryGetNextFrame();
+ if (f is null) return;
+ var n = System.Threading.Interlocked.Increment(ref count);
+ if (n == 1) firstFrameAt = DateTime.UtcNow;
+ if (n >= minFrames && (DateTime.UtcNow - firstFrameAt).TotalSeconds >= postFirstFrameDelaySeconds)
+ {
+ framePool.FrameArrived -= handler;
+ done.TrySetResult(true);
+ }
+ };
+ framePool.FrameArrived += handler;
+ StartCaptureAndNudge(handle);
+
+ try
+ {
+ var winner = await Task.WhenAny(done.Task, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds))).ConfigureAwait(false);
+ if (winner != done.Task)
+ DiagLog($"WaitForFramesAsync: timed out after {timeoutSeconds}s with count={count} firstFrameAt={(firstFrameAt == DateTime.MinValue ? "(none)" : firstFrameAt.ToString("HH:mm:ss.fff"))}");
+ else
+ DiagLog($"WaitForFramesAsync: completed with count={count} elapsedSinceFirstFrame={(DateTime.UtcNow - firstFrameAt).TotalSeconds:F2}s");
+ }
+ finally
+ {
+ CloseSession(handle);
+ }
+ }
+
+ private static async Task WaitAndEncodeAsync(Task frameTask, CaptureSessionHandle handle, string path)
+ {
+ try
+ {
+ var winner = await Task.WhenAny(frameTask, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false);
+ if (winner != frameTask)
+ throw new TimeoutException("WGC FrameArrived didn't fire within 10s; window may be occluded or DWM isn't compositing it.");
+ using var frame = await frameTask.ConfigureAwait(false);
+ EncodeFrameToPng(frame, handle.DeviceAbi, handle.ContextAbi, path);
+ }
+ finally
+ {
+ CloseSession(handle);
+ }
+ }
+
+ private static unsafe void ReleasePointer(IntPtr abi)
+ {
+ if (abi != IntPtr.Zero) ((IUnknown*)abi)->Release();
+ }
+
+
+ private static unsafe void EncodeFrameToPng(
+ Direct3D11CaptureFrame frame,
+ IntPtr deviceAbi,
+ IntPtr contextAbi,
+ string path)
+ {
+ var device = (ID3D11Device*)deviceAbi;
+ var context = (ID3D11DeviceContext*)contextAbi;
+
+ // Reach the underlying ID3D11Texture2D via IDirect3DDxgiInterfaceAccess (CsWinRT's .As
+ // performs the QI through the projection's RCW).
+ var dxgiAccess = frame.Surface.As();
+ var texIid = ID3D11Texture2D.Guid;
+ Marshal.ThrowExceptionForHR(dxgiAccess.GetInterface(ref texIid, out var srcTexUnk));
+ ID3D11Texture2D* srcTex = (ID3D11Texture2D*)srcTexUnk;
+ try
+ {
+ Texture2DDesc desc;
+ srcTex->GetDesc(&desc);
+
+ // CPU-readable staging copy so we can map and read pixels.
+ var stagingDesc = desc;
+ stagingDesc.Usage = Usage.Staging;
+ stagingDesc.CPUAccessFlags = (uint)CpuAccessFlag.Read;
+ stagingDesc.BindFlags = 0;
+ stagingDesc.MiscFlags = 0;
+ ID3D11Texture2D* staging = null;
+ SilkMarshal.ThrowHResult(device->CreateTexture2D(in stagingDesc, null, ref staging));
+ try
+ {
+ context->CopyResource((ID3D11Resource*)staging, (ID3D11Resource*)srcTex);
+
+ MappedSubresource mapped = default;
+ SilkMarshal.ThrowHResult(context->Map((ID3D11Resource*)staging, 0, Map.Read, 0, ref mapped));
+ try
+ {
+ int w = (int)desc.Width;
+ int h = (int)desc.Height;
+ using var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb);
+ var rect = new Rectangle(0, 0, w, h);
+ var bd = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
+ try
+ {
+ int srcPitch = (int)mapped.RowPitch;
+ int dstPitch = bd.Stride;
+ int rowBytes = w * 4;
+ var src = (byte*)mapped.PData;
+ var dst = (byte*)bd.Scan0;
+ for (int y = 0; y < h; y++)
+ Buffer.MemoryCopy(src + y * srcPitch, dst + y * dstPitch, dstPitch, rowBytes);
+ }
+ finally { bmp.UnlockBits(bd); }
+ bmp.Save(path, ImageFormat.Png);
+ }
+ finally { context->Unmap((ID3D11Resource*)staging, 0); }
+ }
+ finally { staging->Release(); }
+ }
+ finally { srcTex->Release(); }
+ }
+}
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/IUITest.cs b/sources/editor/Stride.GameStudio.AutoTesting/IUITest.cs
new file mode 100644
index 0000000000..93dfd8ef3a
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/IUITest.cs
@@ -0,0 +1,13 @@
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
+// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
+
+namespace Stride.GameStudio.AutoTesting;
+
+///
+/// Author-side test fixture. The class is instantiated by the GameStudio loader after the main
+/// window is up; is then driven on a background task with a context handle.
+///
+public interface IUITest
+{
+ Task Run(IUITestContext ctx);
+}
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs b/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs
new file mode 100644
index 0000000000..37403ce60a
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs
@@ -0,0 +1,128 @@
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
+// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
+
+namespace Stride.GameStudio.AutoTesting;
+
+///
+/// Wait/screenshot/exit primitives handed to the test fixture by the AutoTesting runner.
+///
+public interface IUITestContext
+{
+ /// Opens the .sln pre-positioned on disk by the runner. No-op when no project test is configured.
+ Task OpenProject();
+
+ /// Returns when the editor's asset build queue is empty for two consecutive frames.
+ Task WaitForAssetBuild();
+
+ /// Returns when the shader compile queue is empty for two consecutive frames.
+ Task WaitForShaders();
+
+ /// Returns when the WPF dispatcher has drained to ApplicationIdle priority.
+ Task WaitDispatcherIdle();
+
+ /// Awaits N successive render frames.
+ Task WaitFrames(int n = 1);
+
+ /// Convenience: awaits asset build, shaders, dispatcher idle, one trailing frame, and rendering.
+ Task WaitIdle();
+
+ ///
+ /// Returns when every active EditorServiceGame instance — the embedded scene/prefab/UI/sprite
+ /// document games and the shared asset-preview game — has advanced its DrawTime.FrameCount
+ /// by at least since the call started, ensuring swap-chains have
+ /// presented real content. No-op if no games are active. Times out after
+ /// with a log message — never throws.
+ ///
+ Task WaitForRendering(int frames = 60, double timeoutSeconds = 30);
+
+ /// Captures the active main window to a PNG named .
+ Task Screenshot(string name);
+
+ ///
+ /// Resizes a top-level window (looked up by class name) to a fixed ×
+ /// and centers it on the primary screen. Used by fixtures to pin the
+ /// main editor window to a deterministic capture size before .
+ ///
+ Task SetWindowSize(string windowTypeName, int width, int height);
+
+ ///
+ /// Floats a single docked panel or document into its own window sized to
+ /// × , captures it via WGC, then restores its original docked / auto-hide
+ /// state. Lookup by ContentId for anchorable panels (e.g. "AssetView", "PropertyGrid",
+ /// "SolutionExplorer", "BuildLog", "References") or by Title for asset-editor documents
+ /// (e.g. "MainScene") which are added with empty ContentId and Title=asset.Url.
+ ///
+ Task CapturePanel(string idOrTitle, string name, int width = 1200, int height = 900);
+
+ ///
+ /// Polls the WPF Application.Windows set until a Window of class name
+ /// is visible and loaded, or
+ /// elapses. Returns true on success.
+ ///
+ Task WaitForWindow(string windowTypeName, double timeoutSeconds = 120);
+
+ ///
+ /// Selects a template in the ProjectSelectionWindow by template id and returns true if found.
+ /// The dialog stays open; close it via .
+ ///
+ Task SelectTemplate(Guid templateId);
+
+ ///
+ /// Closes a modal dialog with DialogResult.Ok (equivalent to clicking OK / Create).
+ /// Returns true if the window was found and closed.
+ ///
+ Task CloseModalWithOk(string windowTypeName);
+
+ ///
+ /// Equivalent of pressing F5 in GameStudio: builds the current project and launches the resulting
+ /// .exe. Returns the launched process id (or -1 on failure).
+ ///
+ Task RunProject();
+
+ ///
+ /// Polls the process by id until its is
+ /// non-zero, then calls Process.WaitForInputIdle. Returns the HWND or IntPtr.Zero on
+ /// timeout / process exit.
+ ///
+ Task WaitForGameWindow(int pid, double timeoutSeconds = 60);
+
+ ///
+ /// Returns when WGC has delivered frames AND
+ /// have elapsed since the first one (lets TAA-style
+ /// post-effects converge). Default timeout absorbs cold shader-cache builds.
+ ///
+ Task WaitForGameFrames(IntPtr hwnd, int minFrames = 100, double postFirstFrameDelaySeconds = 2.0, double timeoutSeconds = 90);
+
+ /// Captures a specific HWND (e.g. a game window from a child process) to a PNG.
+ Task ScreenshotHwnd(IntPtr hwnd, string name);
+
+ ///
+ /// Sends WM_CLOSE to the game window and waits for the process to exit. Force-kills if
+ /// the process doesn't exit within the timeout.
+ ///
+ Task CloseGameWindow(int pid, double timeoutSeconds = 30);
+
+ ///
+ /// Invokes the same RunAssetTemplate path the asset-templates dialog uses on OK. Pass
+ /// to disambiguate when several templates share an Id (e.g.
+ /// procedural-model variants). Returns the created asset's id, or on
+ /// failure.
+ ///
+ Task AddAssetFromTemplate(Guid templateId, string templateName = null);
+
+ ///
+ /// Registers a one-shot handler for the next AssetPickerWindow: selects the asset by
+ /// Name and confirms. Pass null to cancel the picker.
+ ///
+ Task QueueAssetPickerResponse(string assetName, double timeoutSeconds = 30);
+
+ ///
+ /// Adds an entity to the open scene with a ModelComponent referencing
+ /// , at . Goes through
+ /// CreateEntityInRootCommand with a custom IEntityFactory.
+ ///
+ Task AddEntityToScene(string entityName, Guid modelAssetId, Stride.Core.Mathematics.Vector3 position);
+
+ /// Sets the process exit code and shuts the editor down.
+ void Exit(int exitCode = 0);
+}
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/Program.cs b/sources/editor/Stride.GameStudio.AutoTesting/Program.cs
new file mode 100644
index 0000000000..bfb3dc11ae
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/Program.cs
@@ -0,0 +1,101 @@
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
+// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Windows;
+using System.Windows.Threading;
+
+namespace Stride.GameStudio.AutoTesting;
+
+///
+/// Stride.GameStudio.AutoTesting runner entry point. Hosts Stride.GameStudio in-process and
+/// wires into the WPF Application via the appHosted callback.
+///
+/// CLI:
+/// AutoTesting.exe --test-dll <path> --test-name <class> [project.sln] [GS args...]
+///
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] osArgs)
+ {
+ // Bypasses the adapter-needs-an-output filter in GraphicsDeviceManager.FindBestDevices,
+ // so headless runners (no DXGI outputs) can still pick a hardware adapter or fall back
+ // to WARP. Must be set before any Stride code runs.
+ Environment.SetEnvironmentVariable("STRIDE_GRAPHICS_SOFTWARE_RENDERING", "1");
+
+ // Pre-accept the Stride 4.0 privacy policy: PrivacyPolicyHelper would otherwise pop a
+ // modal at startup with no one to click Accept on CI.
+ try
+ {
+ using var subkey = Microsoft.Win32.Registry.CurrentUser
+ .OpenSubKey(@"SOFTWARE\Stride\Agreements\", writable: true)
+ ?? Microsoft.Win32.Registry.CurrentUser.CreateSubKey(@"SOFTWARE\Stride\Agreements\");
+ subkey?.SetValue("Stride-4.0", "True");
+ }
+ catch { /* best-effort — failure shows up as the privacy-policy hang */ }
+
+ // Parse our own args. Anything we don't recognise is forwarded to Stride.GameStudio.Run.
+ string? testDll = null;
+ string? testName = null;
+ var gsArgs = new List();
+ for (var i = 0; i < osArgs.Length; i++)
+ {
+ if (osArgs[i] == "--test-dll" && i + 1 < osArgs.Length)
+ {
+ testDll = osArgs[++i];
+ }
+ else if (osArgs[i] == "--test-name" && i + 1 < osArgs.Length)
+ {
+ testName = osArgs[++i];
+ }
+ else
+ {
+ gsArgs.Add(osArgs[i]);
+ }
+ }
+ if (testDll is null)
+ {
+ Console.Error.WriteLine("usage: Stride.GameStudio.AutoTesting --test-dll [--test-name ] [project.sln] [GS args...]");
+ return 2;
+ }
+ if (!File.Exists(testDll))
+ {
+ Console.Error.WriteLine($"Test DLL not found: {testDll}");
+ return 2;
+ }
+
+ // GS's CrashReport ends with Environment.Exit(0) which masks the underlying error;
+ // capture every exception (including the swallowed ones) to a diag log.
+ var diagPath = Path.Combine(Path.GetTempPath(), "autotest-diag.log");
+ try { File.Delete(diagPath); } catch { }
+ void Diag(string msg) { try { File.AppendAllText(diagPath, $"{DateTime.UtcNow:HH:mm:ss.fff} {msg}\n"); } catch { } }
+ Diag($"AutoTesting.Main entered. testDll={testDll} testName={testName} gsArgs=[{string.Join(", ", gsArgs)}]");
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ Diag($"UnhandledException terminating={e.IsTerminating}: {e.ExceptionObject}");
+ AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
+ Diag($"FirstChance: {e.Exception.GetType().Name}: {e.Exception.Message}");
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => Diag("ProcessExit");
+
+ UITestHost? host = null;
+ try
+ {
+ Stride.GameStudio.Program.Run(gsArgs, (app, dispatcher) =>
+ {
+ Diag("appHosted callback fired; constructing UITestHost");
+ host = new UITestHost(dispatcher, testDll, testName);
+ host.Start();
+ Diag("UITestHost.Start returned");
+ });
+ Diag("Stride.GameStudio.Program.Run returned");
+ }
+ catch (Exception ex)
+ {
+ Diag($"EXCEPTION escaped Program.Run: {ex}");
+ throw;
+ }
+
+ return host?.ExitCode ?? 0;
+ }
+}
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj b/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj
new file mode 100644
index 0000000000..5cd4a9491f
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj
@@ -0,0 +1,51 @@
+
+
+
+ WinExe
+
+ $(StrideEditorTargetFramework)10.0.22621.0
+
+ $(StrideEditorTargetFramework)10.0.22621
+ win-x64
+ false
+ enable
+ enable
+ true
+ true
+ true
+
+ app.manifest
+
+ true
+ false
+
+
+
+ Properties\SharedAssemblyInfo.cs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/UITestAttribute.cs b/sources/editor/Stride.GameStudio.AutoTesting/UITestAttribute.cs
new file mode 100644
index 0000000000..67d1d6aa3e
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/UITestAttribute.cs
@@ -0,0 +1,14 @@
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
+// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
+
+namespace Stride.GameStudio.AutoTesting;
+
+[AttributeUsage(AttributeTargets.Class)]
+public sealed class UITestAttribute : Attribute
+{
+ ///
+ /// Optional template GUID. The test runner uses it to regenerate the matching sample on disk
+ /// before launching GameStudio; the harness itself does not consult this field.
+ ///
+ public string? SampleTemplateId { get; set; }
+}
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/UITestHost.cs b/sources/editor/Stride.GameStudio.AutoTesting/UITestHost.cs
new file mode 100644
index 0000000000..84a2ffb501
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/UITestHost.cs
@@ -0,0 +1,869 @@
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
+// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using AvalonDock.Layout;
+using Stride.Assets.Presentation.AssetEditors.EntityHierarchyEditor.EntityFactories;
+using Stride.Assets.Presentation.AssetEditors.EntityHierarchyEditor.ViewModels;
+using Stride.Assets.Presentation.AssetEditors.GameEditor.Services;
+using Stride.Assets.Presentation.AssetEditors.GameEditor.ViewModels;
+using Stride.Assets.Presentation.AssetEditors.SceneEditor.ViewModels;
+using Stride.GameStudio.AssetsEditors;
+using Stride.Core.Assets.Editor.Components.TemplateDescriptions.ViewModels;
+using Stride.Core.Assets.Editor.Components.TemplateDescriptions.Views;
+using Stride.Core.Assets.Editor.Services;
+using Stride.Core.Assets.Editor.View;
+using Stride.Core.Assets.Editor.ViewModel;
+using Stride.Core.Assets.Templates;
+using Stride.Core.Mathematics;
+using Stride.Core.Presentation.Controls;
+using Stride.Core.Presentation.Services;
+using Stride.Core.Serialization;
+using Stride.Editor.Build;
+using Stride.Editor.EditorGame.Game;
+using Stride.Editor.Preview;
+using Stride.Engine;
+using Stride.GameStudio.ViewModels;
+using Stride.Rendering;
+
+namespace Stride.GameStudio.AutoTesting;
+
+///
+/// Loads the test DLL, polls for a settled WPF window, runs the chosen
+/// fixture, and provides the impl (waits + WGC capture).
+///
+internal sealed class UITestHost
+{
+ // Output dir suffix is the runtime monitor DPI percentage so per-DPI captures stay separate
+ // (baselines are likewise stored under tests/editor/baselines/dpi/). The runner detects
+ // DPI at startup via GetDpiForMonitor(MDT_EFFECTIVE_DPI), which returns the user-set scale
+ // factor regardless of process DPI-awareness.
+ private const string OutDirNamePrefix = "ui-test-out-dpi";
+ private const string ScreenshotsDir = "screenshots";
+ private const string DoneFileName = "done.json";
+ private const string LogFileName = "log.txt";
+
+ // Window types that indicate the editor has finished startup; transients like
+ // WorkProgressWindow are intentionally excluded.
+ private static readonly HashSet ReadyWindowTypeNames = new(StringComparer.Ordinal)
+ {
+ "GameStudioWindow",
+ "ProjectSelectionWindow",
+ };
+
+ private readonly Dispatcher dispatcher;
+ private readonly string testDllPath;
+ private readonly string? testClassName;
+ private readonly string outputDir;
+ private StreamWriter? logWriter;
+ private readonly List capturedNames = new();
+ private string lastSeenWindowsSummary = "";
+
+ public int ExitCode { get; private set; }
+
+ public UITestHost(Dispatcher dispatcher, string testDllPath, string? testClassName)
+ {
+ this.dispatcher = dispatcher;
+ this.testDllPath = testDllPath;
+ this.testClassName = testClassName;
+ outputDir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(testDllPath))!, OutDirNamePrefix + DpiUtil.DetectDpiPercent());
+ Directory.CreateDirectory(Path.Combine(outputDir, ScreenshotsDir));
+ try { logWriter = new StreamWriter(new FileStream(Path.Combine(outputDir, LogFileName), FileMode.Create, FileAccess.Write, FileShare.Read)) { AutoFlush = true }; }
+ catch (Exception ex) { Console.Error.WriteLine($"UITestHost: failed to open log: {ex.Message}"); }
+ }
+
+ public void Start()
+ {
+ Log($"Start: testDllPath={testDllPath} testClassName={testClassName ?? "(auto)"}");
+ var test = LoadTest();
+ Log($"Test loaded: {test.GetType().FullName}");
+ var ctx = new Context(this);
+
+ // Background polling loop that marshals each window check onto the dispatcher.
+ var fired = false;
+ Task.Run(async () =>
+ {
+ await Task.Delay(2000).ConfigureAwait(false);
+ for (var i = 0; i < 1500; i++)
+ {
+ if (fired) return;
+ bool ready;
+ try { ready = await dispatcher.InvokeAsync(HasReadyWindow).Task.ConfigureAwait(false); }
+ catch (Exception ex) { Log($"Poll: InvokeAsync failed: {ex.Message}"); return; }
+ if (ready)
+ {
+ fired = true;
+ await dispatcher.InvokeAsync(() => RunTest(test, ctx)).Task.ConfigureAwait(false);
+ return;
+ }
+ await Task.Delay(200).ConfigureAwait(false);
+ }
+ Log("Poll: gave up after 1500 iterations (~5min).");
+ });
+ }
+
+ private void Log(string message)
+ {
+ var line = $"{DateTime.UtcNow:HH:mm:ss.fff} {message}";
+ Console.Error.WriteLine(line);
+ try { logWriter?.WriteLine(line); }
+ catch { /* best-effort */ }
+ }
+
+ private bool HasReadyWindow()
+ {
+ var app = Application.Current;
+ if (app is null) return false;
+ var summary = string.Join(", ", app.Windows.OfType().Select(w =>
+ $"{w.GetType().Name}[Title='{w.Title}'](visible={w.IsVisible},loaded={w.IsLoaded},{w.ActualWidth}x{w.ActualHeight})"));
+ if (summary != lastSeenWindowsSummary)
+ {
+ Log($"windows: {summary}");
+ lastSeenWindowsSummary = summary;
+ }
+ foreach (var win in app.Windows.OfType())
+ {
+ if (!win.IsVisible || !win.IsLoaded) continue;
+ if (win.ActualWidth < 100 || win.ActualHeight < 100) continue;
+ if (!ReadyWindowTypeNames.Contains(win.GetType().Name)) continue;
+ Log($"ready: '{win.GetType().Name}' Title='{win.Title}' Size={win.ActualWidth}x{win.ActualHeight}");
+ return true;
+ }
+ return false;
+ }
+
+ private IUITest LoadTest()
+ {
+ var asm = Assembly.LoadFrom(testDllPath);
+ var candidates = asm.GetTypes()
+ .Where(t => t.GetCustomAttribute() is not null)
+ .ToList();
+ if (candidates.Count == 0)
+ throw new InvalidOperationException($"No [UITest] class found in '{testDllPath}'.");
+
+ Type chosen;
+ if (testClassName is not null)
+ {
+ chosen = candidates.FirstOrDefault(t => t.Name == testClassName || t.FullName == testClassName)
+ ?? throw new InvalidOperationException($"No [UITest] class named '{testClassName}' in '{testDllPath}'. Available: {string.Join(", ", candidates.Select(t => t.FullName))}");
+ }
+ else if (candidates.Count == 1)
+ {
+ chosen = candidates[0];
+ }
+ else
+ {
+ throw new InvalidOperationException($"Multiple [UITest] classes in '{testDllPath}'; pass --test-name to select. Available: {string.Join(", ", candidates.Select(t => t.FullName))}");
+ }
+
+ if (!typeof(IUITest).IsAssignableFrom(chosen))
+ throw new InvalidOperationException($"[UITest] class {chosen.FullName} must implement IUITest.");
+
+ return (IUITest)Activator.CreateInstance(chosen)!;
+ }
+
+ private void RunTest(IUITest test, Context ctx)
+ {
+ Task.Run(async () =>
+ {
+ var status = "ok";
+ object? exceptionInfo = null;
+ try
+ {
+ await test.Run(ctx);
+ }
+ catch (Exception ex)
+ {
+ status = "error";
+ exceptionInfo = SerializeException(ex);
+ Console.Error.WriteLine(ex);
+ ExitCode = 1;
+ }
+ finally
+ {
+ WriteDoneJson(status, exceptionInfo);
+ ctx.ShutdownInternal();
+ }
+ });
+ }
+
+ private void WriteDoneJson(string status, object? exceptionInfo)
+ {
+ try
+ {
+ var donePath = Path.Combine(outputDir, DoneFileName);
+ // Editor UI drifts nondeterministically; Claude fallback fires only when LPIPS exceeds
+ // threshold so cost is bounded.
+ var payload = new
+ {
+ status,
+ screenshots = capturedNames.Select(n => new { name = n, claudeFallback = true }),
+ exception = exceptionInfo,
+ };
+ File.WriteAllText(donePath, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"UITestHost: failed to write done.json: {ex}");
+ }
+ }
+
+ private static object SerializeException(Exception ex) => new
+ {
+ type = ex.GetType().FullName,
+ message = ex.Message,
+ stack = ex.ToString(),
+ };
+
+ private sealed class Context : IUITestContext
+ {
+ private readonly UITestHost host;
+ public Context(UITestHost host) { this.host = host; }
+
+ public Task OpenProject() => Task.CompletedTask; // project is loaded by GameStudio's positional-arg path before the test runs
+
+ public Task WaitForAssetBuild() => WaitForQueueDrained("asset-build", () =>
+ {
+ var session = TryGetSession();
+ return session?.ServiceProvider.TryGet()?.QueuedBuildUnitCount ?? 0;
+ });
+
+ public Task WaitForShaders() => WaitForQueueDrained("shader-compile", () =>
+ {
+ var session = TryGetSession();
+ return session?.ServiceProvider.TryGet()?.PendingShaderCompilationCount ?? 0;
+ });
+
+ public Task WaitDispatcherIdle()
+ {
+ var tcs = new TaskCompletionSource();
+ host.dispatcher.BeginInvoke(() => tcs.SetResult(), DispatcherPriority.ApplicationIdle);
+ return tcs.Task;
+ }
+
+ public async Task WaitFrames(int n = 1)
+ {
+ for (var i = 0; i < n; i++)
+ await WaitDispatcherIdle();
+ }
+
+ public async Task WaitIdle()
+ {
+ await WaitForAssetBuild();
+ await WaitForShaders();
+ await WaitDispatcherIdle();
+ await WaitFrames(1);
+ await WaitForRendering();
+ }
+
+ public async Task WaitForRendering(int frames = 60, double timeoutSeconds = 30)
+ {
+ // Snapshot the (game, startFrameCount) pairs on the dispatcher; reading EditorServiceGame
+ // state from the WPF UI thread is the safe path. PreviewGame lives on its own thread but
+ // its DrawTime.FrameCount property is just an int read.
+ var watched = await host.dispatcher.InvokeAsync(EnumerateActiveGames).Task.ConfigureAwait(false);
+ if (watched.Count == 0)
+ {
+ host.Log("WaitForRendering: no active EditorServiceGame instances — skipping");
+ return;
+ }
+ host.Log($"WaitForRendering: watching {watched.Count} game(s) for ≥{frames} frames each (timeout {timeoutSeconds}s)");
+
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(100).ConfigureAwait(false);
+ var allReady = true;
+ foreach (var w in watched)
+ {
+ int current;
+ try { current = w.Game.DrawTime?.FrameCount ?? w.StartFrame; }
+ catch { continue; } // game disposed mid-wait — treat as ready (drop from watch list semantically)
+ if (current - w.StartFrame < frames) { allReady = false; break; }
+ }
+ if (allReady)
+ {
+ host.Log($"WaitForRendering: all watched games advanced ≥{frames} frames");
+ return;
+ }
+ }
+ var snapshot = string.Join(", ", watched.Select(w =>
+ {
+ int? cur; try { cur = w.Game.DrawTime?.FrameCount; } catch { cur = null; }
+ return $"{w.Game.GetType().Name}={(cur is null ? "?" : (cur - w.StartFrame).ToString())}";
+ }));
+ host.Log($"WaitForRendering: timed out after {timeoutSeconds}s — advances {snapshot}");
+ }
+
+ private readonly record struct WatchedGame(EditorServiceGame Game, int StartFrame);
+
+ ///
+ /// Walks the session's preview service + open asset-editor list and returns one entry per
+ /// running with its current .
+ /// Reflection-based: AssetEditorsManager.assetEditors is private, and
+ /// EditorGameController<T>.Game is a protected field — neither is reachable from
+ /// the AutoTesting assembly without [InternalsVisibleTo], which we don't need to add for
+ /// this read-only diagnostic walk.
+ ///
+ private List EnumerateActiveGames()
+ {
+ var list = new List();
+ var session = TryGetSession();
+ if (session is null) return list;
+
+ // 1) Shared asset-preview game (runs on its own STA thread, drives thumbnail rendering).
+ var previewSvc = session.ServiceProvider.TryGet();
+ if (previewSvc?.PreviewGame is { IsRunning: true } previewGame)
+ list.Add(new WatchedGame(previewGame, previewGame.DrawTime?.FrameCount ?? 0));
+
+ // 2) Each open asset editor's embedded game.
+ if (session.ServiceProvider.TryGet() is not AssetEditorsManager aem) return list;
+ foreach (var editorVm in aem.EditorViewModels)
+ {
+ if (editorVm is GameEditorViewModel { Controller: IEditorGameAccess access } && access.EditorGame is { IsRunning: true } game)
+ list.Add(new WatchedGame(game, game.DrawTime?.FrameCount ?? 0));
+ }
+ return list;
+ }
+
+ ///
+ /// Returns when reads zero on two consecutive idle ticks.
+ /// The two-tick rule absorbs the race where one drain seeds the queue from a follow-up.
+ ///
+ private async Task WaitForQueueDrained(string label, Func getCount)
+ {
+ const int RequiredStableTicks = 2;
+ var deadline = DateTime.UtcNow.AddSeconds(120);
+ var stable = 0;
+ var lastLogged = -1;
+ var nextLogAt = DateTime.UtcNow.AddSeconds(2);
+ while (DateTime.UtcNow < deadline)
+ {
+ await WaitDispatcherIdle();
+ var count = await host.dispatcher.InvokeAsync(getCount, DispatcherPriority.ApplicationIdle);
+ if (DateTime.UtcNow >= nextLogAt && count != lastLogged)
+ {
+ host.Log($"WaitForQueueDrained('{label}') count={count}");
+ lastLogged = count;
+ nextLogAt = DateTime.UtcNow.AddSeconds(2);
+ }
+ if (count == 0)
+ {
+ if (++stable >= RequiredStableTicks) return;
+ }
+ else
+ {
+ stable = 0;
+ }
+ }
+ host.Log($"WaitForQueueDrained('{label}') timed out after 120s.");
+ }
+
+ private static SessionViewModel? TryGetSession()
+ {
+ var app = Application.Current;
+ if (app is null) return null;
+ foreach (var w in app.Windows.OfType())
+ {
+ if (w.DataContext is GameStudioViewModel gs) return gs.Session;
+ }
+ return null;
+ }
+
+ public async Task Screenshot(string name)
+ {
+ var window = await host.dispatcher.InvokeAsync(ResolveCaptureWindow).Task.ConfigureAwait(false);
+ if (window is null)
+ {
+ host.Log("Screenshot: no window to capture.");
+ return;
+ }
+ var (winInfo, hwnd) = await host.dispatcher.InvokeAsync(() =>
+ ($"'{window.GetType().Name}' Title='{window.Title}' Size={window.ActualWidth}x{window.ActualHeight}",
+ new WindowInteropHelper(window).Handle)).Task.ConfigureAwait(false);
+ host.Log($"Screenshot: capturing {winInfo}");
+
+ // Force a fresh WPF render so DWM has a frame for WGC to capture.
+ await host.dispatcher.InvokeAsync(() =>
+ {
+ window.Activate();
+ window.InvalidateVisual();
+ window.UpdateLayout();
+ }, DispatcherPriority.Normal).Task.ConfigureAwait(false);
+ await host.dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render).Task.ConfigureAwait(false);
+ await Task.Delay(150).ConfigureAwait(false);
+
+ try
+ {
+ var path = Path.Combine(host.outputDir, ScreenshotsDir, name + ".png");
+ if (hwnd == IntPtr.Zero) throw new InvalidOperationException("Window has no HWND yet.");
+ await GraphicsCaptureClient.CaptureToPngAsync(hwnd, path).ConfigureAwait(false);
+ host.capturedNames.Add(name);
+ }
+ catch (Exception ex)
+ {
+ host.Log($"Screenshot('{name}') failed: {ex}");
+ }
+ }
+
+ public async Task WaitForWindow(string windowTypeName, double timeoutSeconds = 120)
+ {
+ host.Log($"WaitForWindow: '{windowTypeName}' (timeout={timeoutSeconds}s)");
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ var found = await host.dispatcher.InvokeAsync(() =>
+ {
+ var app = Application.Current;
+ return app?.Windows.OfType().Any(w =>
+ w.GetType().Name == windowTypeName && w.IsVisible && w.IsLoaded
+ && w.ActualWidth >= 100 && w.ActualHeight >= 100) ?? false;
+ }).Task.ConfigureAwait(false);
+ if (found)
+ {
+ host.Log($"WaitForWindow: '{windowTypeName}' ready");
+ return true;
+ }
+ await Task.Delay(200).ConfigureAwait(false);
+ }
+ host.Log($"WaitForWindow: '{windowTypeName}' timed out after {timeoutSeconds}s");
+ return false;
+ }
+
+ public Task SelectTemplate(Guid templateId) =>
+ host.dispatcher.InvokeAsync(() =>
+ {
+ host.Log($"SelectTemplate: {templateId}");
+ var app = Application.Current;
+ if (app is null) return false;
+ var win = app.Windows.OfType().FirstOrDefault();
+ if (win is null) { host.Log("SelectTemplate: ProjectSelectionWindow not found"); return false; }
+ var collection = win.Templates;
+ if (collection is null) { host.Log("SelectTemplate: ProjectSelectionWindow.Templates is null"); return false; }
+ // Templates is the per-group filtered view; full set lives behind RootGroups.
+ var candidates = collection.Templates
+ .Concat(collection.RootGroups.SelectMany(g => g.GetTemplatesRecursively()))
+ .ToList();
+ var match = candidates.FirstOrDefault(t => t.Id == templateId);
+ if (match is null)
+ { host.Log($"SelectTemplate: no template with Id={templateId} in {candidates.Count} candidates"); return false; }
+ collection.SelectedTemplate = match;
+ host.Log($"SelectTemplate: selected '{match.GetType().Name}' (Id={match.Id})");
+ return true;
+ }).Task;
+
+ public Task CloseModalWithOk(string windowTypeName) =>
+ host.dispatcher.InvokeAsync(() =>
+ {
+ host.Log($"CloseModalWithOk: '{windowTypeName}'");
+ var app = Application.Current;
+ if (app is null) return false;
+ var win = app.Windows.OfType().FirstOrDefault(w => w.GetType().Name == windowTypeName);
+ if (win is null) { host.Log($"CloseModalWithOk: '{windowTypeName}' not found"); return false; }
+ if (win is ModalWindow modal)
+ {
+ modal.RequestClose(DialogResult.Ok);
+ host.Log($"CloseModalWithOk: RequestClose(Ok) on '{windowTypeName}'");
+ return true;
+ }
+ win.Close();
+ host.Log($"CloseModalWithOk: Close() on '{windowTypeName}' (not a ModalWindow)");
+ return true;
+ }).Task;
+
+ public async Task SetWindowSize(string windowTypeName, int width, int height) =>
+ await host.dispatcher.InvokeAsync(() =>
+ {
+ var work = SystemParameters.WorkArea;
+ // Clamp to work area so the window stays fully on-screen — partially off-screen
+ // windows confuse DWM redirection and break WGC capture downstream.
+ var w = Math.Min(width, (int)work.Width);
+ var h = Math.Min(height, (int)work.Height);
+ host.Log($"SetWindowSize: '{windowTypeName}' → req {width}x{height} clamped {w}x{h} (work={work.Width}x{work.Height})");
+ var win = Application.Current?.Windows.OfType()
+ .FirstOrDefault(w0 => w0.GetType().Name == windowTypeName);
+ if (win is null) { host.Log($"SetWindowSize: '{windowTypeName}' not found"); return; }
+ win.SetCurrentValue(Window.WindowStateProperty, WindowState.Normal);
+ win.SetCurrentValue(Window.SizeToContentProperty, SizeToContent.Manual);
+ win.SetCurrentValue(Window.WidthProperty, (double)w);
+ win.SetCurrentValue(Window.HeightProperty, (double)h);
+ win.SetCurrentValue(Window.LeftProperty, work.Left + Math.Max(0.0, (work.Width - w) / 2.0));
+ win.SetCurrentValue(Window.TopProperty, work.Top + Math.Max(0.0, (work.Height - h) / 2.0));
+ win.UpdateLayout();
+ }, DispatcherPriority.Render).Task.ConfigureAwait(false);
+
+ public async Task CapturePanel(string idOrTitle, string name, int width = 1200, int height = 900)
+ {
+ host.Log($"CapturePanel: id='{idOrTitle}' name='{name}' size={width}x{height}");
+ var path = Path.Combine(host.outputDir, ScreenshotsDir, name + ".png");
+ LayoutAnchorable? anchorable = null;
+ AnchorableState? originalState = null;
+ try
+ {
+ anchorable = await host.dispatcher.InvokeAsync(() => FindAnchorable(idOrTitle)).Task.ConfigureAwait(false);
+ if (anchorable is null) { host.Log($"CapturePanel: '{idOrTitle}' not found."); return; }
+
+ originalState = await host.dispatcher.InvokeAsync(() => FloatAnchorable(anchorable, width, height)).Task.ConfigureAwait(false);
+
+ // Let the floating window realize and lay out.
+ await WaitDispatcherIdle();
+ await Task.Delay(250).ConfigureAwait(false);
+ await WaitDispatcherIdle();
+
+ await host.dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render).Task.ConfigureAwait(false);
+ await Task.Delay(150).ConfigureAwait(false);
+
+ var (winInfo, hwnd) = await host.dispatcher.InvokeAsync(() =>
+ {
+ var floatWin = FindFloatingWindow(idOrTitle)
+ ?? throw new InvalidOperationException($"Floating window for '{idOrTitle}' not found after Float().");
+ floatWin.UpdateLayout();
+ return ($"'{floatWin.GetType().Name}' Size={floatWin.ActualWidth}x{floatWin.ActualHeight}",
+ new WindowInteropHelper(floatWin).Handle);
+ }).Task.ConfigureAwait(false);
+ host.Log($"CapturePanel: capturing {winInfo}");
+ if (hwnd == IntPtr.Zero) throw new InvalidOperationException("Floating window has no HWND yet.");
+
+ // WGC captures DWM composition output, including D3DImage interop content like the
+ // embedded scene preview's swap-chain.
+ await GraphicsCaptureClient.CaptureToPngAsync(hwnd, path).ConfigureAwait(false);
+ host.Log($"CapturePanel: wrote → {path}");
+ host.capturedNames.Add(name);
+ }
+ catch (Exception ex)
+ {
+ host.Log($"CapturePanel('{idOrTitle}','{name}') failed: {ex}");
+ }
+ finally
+ {
+ if (anchorable is not null && originalState is not null)
+ {
+ try
+ {
+ await host.dispatcher.InvokeAsync(() => RestoreAnchorable(anchorable, originalState.Value)).Task.ConfigureAwait(false);
+ }
+ catch (Exception ex) { host.Log($"CapturePanel: restore failed: {ex}"); }
+ }
+ }
+ }
+
+ ///
+ /// Walks and returns the first top-level Window whose visual
+ /// tree contains the LayoutAnchorable for — i.e. the floating
+ /// window AvalonDock spawned by Float(). Skips the main GameStudioWindow.
+ ///
+ private static Window? FindFloatingWindow(string contentId)
+ {
+ var app = Application.Current;
+ if (app is null) return null;
+ foreach (var w in app.Windows.OfType())
+ {
+ if (w.GetType().Name == "GameStudioWindow") continue;
+ if (SearchTree(w, contentId, returnElement: false) is not null)
+ return w;
+ }
+ return null;
+ }
+
+ /// Finds the AvalonDock with the matching ContentId.
+ private static LayoutAnchorable? FindAnchorable(string contentId)
+ {
+ var app = Application.Current;
+ if (app is null) return null;
+ foreach (var w in app.Windows.OfType())
+ {
+ if (SearchTree(w, contentId, returnElement: false) is LayoutAnchorable hit) return hit;
+ }
+ return null;
+ }
+
+ private static object? SearchTree(DependencyObject node, string idOrTitle, bool returnElement)
+ {
+ // Anchorables (panels) match by ContentId; documents (asset editors) typically have
+ // an empty ContentId and identify via Title (the asset URL).
+ if (node is FrameworkElement fe && fe.DataContext is LayoutContent lc
+ && (string.Equals(lc.ContentId, idOrTitle, StringComparison.Ordinal)
+ || string.Equals(lc.Title, idOrTitle, StringComparison.Ordinal)))
+ return returnElement ? fe : lc;
+ var count = VisualTreeHelper.GetChildrenCount(node);
+ for (var i = 0; i < count; i++)
+ {
+ var child = VisualTreeHelper.GetChild(node, i);
+ var hit = SearchTree(child, idOrTitle, returnElement);
+ if (hit is not null) return hit;
+ }
+ return null;
+ }
+
+ private readonly record struct AnchorableState(bool WasAutoHidden, bool WasFloating, double OldFloatingWidth, double OldFloatingHeight);
+
+ private static AnchorableState FloatAnchorable(LayoutAnchorable anchorable, int width, int height)
+ {
+ var state = new AnchorableState(anchorable.IsAutoHidden, anchorable.IsFloating, anchorable.FloatingWidth, anchorable.FloatingHeight);
+ // Auto-hidden panels must be expanded before Float() can move them; otherwise the
+ // anchorable stays parented to the auto-hide pane and Float() no-ops.
+ if (state.WasAutoHidden) anchorable.ToggleAutoHide();
+ anchorable.FloatingWidth = width;
+ anchorable.FloatingHeight = height;
+ if (!state.WasFloating) anchorable.Float();
+ return state;
+ }
+
+ private static void RestoreAnchorable(LayoutAnchorable anchorable, AnchorableState state)
+ {
+ if (!state.WasFloating) anchorable.Dock();
+ anchorable.FloatingWidth = state.OldFloatingWidth;
+ anchorable.FloatingHeight = state.OldFloatingHeight;
+ if (state.WasAutoHidden && !anchorable.IsAutoHidden) anchorable.ToggleAutoHide();
+ }
+
+ public Task RunProject() =>
+ host.dispatcher.InvokeAsync(async () =>
+ {
+ var debugging = TryGetDebugging();
+ if (debugging is null) { host.Log("RunProject: GameStudioViewModel not found"); return -1; }
+ host.Log("RunProject: invoking RunProjectAsync");
+ var (ok, proc) = await debugging.RunProjectAsync().ConfigureAwait(true);
+ if (!ok || proc is null) { host.Log("RunProject: failed"); return -1; }
+ host.Log($"RunProject: launched pid={proc.Id}");
+ return proc.Id;
+ }).Task.Unwrap();
+
+ public async Task WaitForGameWindow(int pid, double timeoutSeconds = 60)
+ {
+ host.Log($"WaitForGameWindow: pid={pid} timeout={timeoutSeconds}s");
+ Process proc;
+ try { proc = Process.GetProcessById(pid); }
+ catch (ArgumentException) { host.Log($"WaitForGameWindow: pid {pid} not found"); return IntPtr.Zero; }
+
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ if (proc.HasExited) { host.Log($"WaitForGameWindow: pid {pid} exited"); return IntPtr.Zero; }
+ proc.Refresh();
+ var hwnd = proc.MainWindowHandle;
+ if (hwnd != IntPtr.Zero)
+ {
+ try { proc.WaitForInputIdle(2000); } catch { /* not a GUI app, or already idle */ }
+ host.Log($"WaitForGameWindow: hwnd=0x{hwnd.ToInt64():X}");
+ return hwnd;
+ }
+ await Task.Delay(200).ConfigureAwait(false);
+ }
+ host.Log($"WaitForGameWindow: timed out after {timeoutSeconds}s");
+ return IntPtr.Zero;
+ }
+
+ public async Task WaitForGameFrames(IntPtr hwnd, int minFrames = 100, double postFirstFrameDelaySeconds = 2.0, double timeoutSeconds = 90)
+ {
+ host.Log($"WaitForGameFrames: hwnd=0x{hwnd.ToInt64():X} minFrames={minFrames} postFirstFrame={postFirstFrameDelaySeconds}s timeout={timeoutSeconds}s");
+ await GraphicsCaptureClient.WaitForFramesAsync(hwnd, minFrames, postFirstFrameDelaySeconds, timeoutSeconds).ConfigureAwait(false);
+ }
+
+ public async Task ScreenshotHwnd(IntPtr hwnd, string name)
+ {
+ if (hwnd == IntPtr.Zero) { host.Log($"ScreenshotHwnd('{name}'): hwnd is zero"); return; }
+ var path = Path.Combine(host.outputDir, ScreenshotsDir, name + ".png");
+ try
+ {
+ await GraphicsCaptureClient.CaptureToPngAsync(hwnd, path).ConfigureAwait(false);
+ host.capturedNames.Add(name);
+ host.Log($"ScreenshotHwnd: wrote → {path}");
+ }
+ catch (Exception ex) { host.Log($"ScreenshotHwnd('{name}') failed: {ex}"); }
+ }
+
+ public async Task CloseGameWindow(int pid, double timeoutSeconds = 30)
+ {
+ host.Log($"CloseGameWindow: pid={pid} timeout={timeoutSeconds}s");
+ Process proc;
+ try { proc = Process.GetProcessById(pid); }
+ catch (ArgumentException) { host.Log($"CloseGameWindow: pid {pid} not found"); return; }
+ if (proc.HasExited) { host.Log($"CloseGameWindow: pid {pid} already exited"); return; }
+ proc.Refresh();
+ var hwnd = proc.MainWindowHandle;
+ if (hwnd != IntPtr.Zero) PostMessage(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
+ else host.Log("CloseGameWindow: no MainWindowHandle, will rely on Kill");
+ if (!await Task.Run(() => proc.WaitForExit((int)(timeoutSeconds * 1000))).ConfigureAwait(false))
+ {
+ host.Log("CloseGameWindow: WM_CLOSE timed out, killing");
+ try { proc.Kill(entireProcessTree: true); } catch (Exception ex) { host.Log($"Kill failed: {ex.Message}"); }
+ }
+ // Process.ExitCode requires a handle the runtime retained from Start; pids opened via
+ // GetProcessById don't qualify. Just log exit.
+ host.Log($"CloseGameWindow: pid={pid} exited");
+ }
+
+ private const int WM_CLOSE = 0x0010;
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
+ private static extern bool PostMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
+
+ private static DebuggingViewModel TryGetDebugging()
+ {
+ var app = Application.Current;
+ if (app is null) return null;
+ foreach (var w in app.Windows.OfType())
+ {
+ if (w.DataContext is GameStudioViewModel gs) return gs.Debugging;
+ }
+ return null;
+ }
+
+ public Task AddAssetFromTemplate(Guid templateId, string templateName = null) =>
+ host.dispatcher.InvokeAsync(async () =>
+ {
+ var session = TryGetSession();
+ if (session is null) { host.Log("AddAssetFromTemplate: no session"); return Guid.Empty; }
+ // Procedural-model variants all share the generator's TemplateId; Name disambiguates.
+ var matches = session.FindTemplates(TemplateScope.Asset).Where(t => t.Id == templateId).ToList();
+ var template = templateName is null
+ ? matches.FirstOrDefault()
+ : matches.FirstOrDefault(t => string.Equals(t.Name, templateName, StringComparison.Ordinal));
+ if (template is null)
+ {
+ host.Log($"AddAssetFromTemplate: template id={templateId} name='{templateName ?? "*"}' not found ({matches.Count} sharing Id: {string.Join(",", matches.Select(t => t.Name))})");
+ return Guid.Empty;
+ }
+ var assetView = session.ActiveAssetView;
+ if (assetView is null) { host.Log("AddAssetFromTemplate: ActiveAssetView is null"); return Guid.Empty; }
+ var templateVm = new TemplateDescriptionViewModel(session.ServiceProvider, template);
+ var created = await assetView.RunAssetTemplate(templateVm, null).ConfigureAwait(true);
+ if (created is null || created.Count == 0) { host.Log("AddAssetFromTemplate: RunAssetTemplate returned no asset"); return Guid.Empty; }
+ host.Log($"AddAssetFromTemplate: created '{created[0].Url}' (id={created[0].Id})");
+ return (Guid)created[0].Id;
+ }).Task.Unwrap();
+
+ public async Task QueueAssetPickerResponse(string assetName, double timeoutSeconds = 30)
+ {
+ host.Log($"QueueAssetPickerResponse: assetName='{assetName ?? ""}' timeout={timeoutSeconds}s");
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ // Poll for the AssetPickerWindow to appear, then resolve it on the dispatcher.
+ while (DateTime.UtcNow < deadline)
+ {
+ var resolved = await host.dispatcher.InvokeAsync(() => TryResolveAssetPicker(assetName)).Task.ConfigureAwait(false);
+ if (resolved) return;
+ await Task.Delay(150).ConfigureAwait(false);
+ }
+ host.Log($"QueueAssetPickerResponse: timed out — no AssetPickerWindow appeared within {timeoutSeconds}s");
+ }
+
+ private bool TryResolveAssetPicker(string assetName)
+ {
+ var picker = Application.Current?.Windows.OfType().FirstOrDefault(w => w.IsLoaded && w.IsVisible);
+ if (picker is null) return false;
+ if (assetName is null)
+ {
+ picker.RequestClose(DialogResult.Cancel);
+ host.Log("QueueAssetPickerResponse: cancelled picker");
+ return true;
+ }
+ var asset = picker.Session.AllAssets.FirstOrDefault(a => string.Equals(a.Name, assetName, StringComparison.Ordinal));
+ if (asset is null)
+ {
+ host.Log($"QueueAssetPickerResponse: asset '{assetName}' not found; cancelling");
+ picker.RequestClose(DialogResult.Cancel);
+ return true;
+ }
+ picker.AssetView.SelectAssets(new[] { asset });
+ picker.RequestClose(DialogResult.Ok);
+ host.Log($"QueueAssetPickerResponse: selected '{asset.Url}' (id={asset.Id}) and confirmed");
+ return true;
+ }
+
+ public Task AddEntityToScene(string entityName, Guid modelAssetId, Vector3 position) =>
+ host.dispatcher.InvokeAsync(() =>
+ {
+ var session = TryGetSession();
+ if (session is null) { host.Log("AddEntityToScene: no session"); return false; }
+ var modelAsset = session.AllAssets.FirstOrDefault(a => (Guid)a.Id == modelAssetId);
+ if (modelAsset is null) { host.Log($"AddEntityToScene: model asset id={modelAssetId} not found"); return false; }
+ var sceneEditor = TryGetOpenSceneEditor();
+ if (sceneEditor is null) { host.Log("AddEntityToScene: no SceneEditorViewModel open"); return false; }
+ var factory = new ModelEntityFactory(entityName, modelAsset.Id, modelAsset.Url, position);
+ sceneEditor.CreateEntityInRootCommand.Execute(factory);
+ host.Log($"AddEntityToScene: '{entityName}' factory dispatched (modelAsset='{modelAsset.Url}', position={position})");
+ return true;
+ }).Task;
+
+ private static SceneEditorViewModel TryGetOpenSceneEditor()
+ {
+ var session = TryGetSession();
+ if (session?.ServiceProvider.TryGet() is not AssetEditorsManager aem) return null;
+ return aem.EditorViewModels.OfType().FirstOrDefault();
+ }
+
+ ///
+ /// Entity with ModelComponent + transform pre-set. CreateEntityInRootCommand
+ /// preserves the factory-set position (its mouse-position branch is skipped).
+ ///
+ private sealed class ModelEntityFactory : IEntityFactory
+ {
+ private readonly string entityName;
+ private readonly Stride.Core.Assets.AssetId modelId;
+ private readonly string modelUrl;
+ private readonly Vector3 position;
+
+ public ModelEntityFactory(string entityName, Stride.Core.Assets.AssetId modelId, string modelUrl, Vector3 position)
+ {
+ this.entityName = entityName;
+ this.modelId = modelId;
+ this.modelUrl = modelUrl;
+ this.position = position;
+ }
+
+ public Task CreateEntity(EntityHierarchyItemViewModel parent)
+ {
+ var entity = new Entity(entityName);
+ entity.Transform.Position = position;
+ var modelRef = AttachedReferenceManager.CreateProxyObject(modelId, modelUrl);
+ entity.Add(new ModelComponent { Model = modelRef });
+ return Task.FromResult(entity);
+ }
+ }
+
+ public void Exit(int newExitCode = 0)
+ {
+ host.ExitCode = newExitCode;
+ ShutdownInternal();
+ }
+
+ public void ShutdownInternal()
+ {
+ host.dispatcher.BeginInvoke(() =>
+ {
+ Environment.ExitCode = host.ExitCode;
+ var app = Application.Current;
+ if (app is null) return;
+ foreach (var win in app.Windows.Cast().ToList())
+ {
+ try { win.Close(); } catch { /* best-effort */ }
+ }
+ app.Shutdown(host.ExitCode);
+ });
+ }
+
+ private static Window? ResolveCaptureWindow()
+ {
+ var app = Application.Current;
+ if (app is null) return null;
+ return app.Windows.OfType().FirstOrDefault(w => w.IsActive)
+ ?? app.Windows.OfType().LastOrDefault(w => w.IsLoaded)
+ ?? app.MainWindow;
+ }
+ }
+}
diff --git a/sources/editor/Stride.GameStudio.AutoTesting/app.manifest b/sources/editor/Stride.GameStudio.AutoTesting/app.manifest
new file mode 100644
index 0000000000..28132d0a9f
--- /dev/null
+++ b/sources/editor/Stride.GameStudio.AutoTesting/app.manifest
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ unaware
+
+
+
diff --git a/sources/editor/Stride.GameStudio/App.xaml b/sources/editor/Stride.GameStudio/App.xaml
index adc22a989f..020eb7ab3a 100644
--- a/sources/editor/Stride.GameStudio/App.xaml
+++ b/sources/editor/Stride.GameStudio/App.xaml
@@ -4,7 +4,7 @@
-
+
diff --git a/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs b/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs
index c5ba02cd8c..eab9450655 100644
--- a/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs
+++ b/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs
@@ -31,6 +31,8 @@ internal sealed class AssetEditorsManager : IAssetEditorsManager, IDestroyable
{
private readonly ConditionalWeakTable registeredHandlers = [];
private readonly Dictionary assetEditors = [];
+
+ internal IEnumerable EditorViewModels => assetEditors.Keys;
private readonly Dictionary openedAssets = [];
// TODO have a base interface for all editors and factorize to make curve editor not be a special case anymore
private Tuple curveEditor;
diff --git a/sources/editor/Stride.GameStudio/Program.cs b/sources/editor/Stride.GameStudio/Program.cs
index 7bccfc7dbd..fcbb80cb0d 100644
--- a/sources/editor/Stride.GameStudio/Program.cs
+++ b/sources/editor/Stride.GameStudio/Program.cs
@@ -60,9 +60,28 @@ public static class Program
private static readonly ConcurrentQueue LogRingbuffer = new();
private static bool enableThumbnailServices = true;
+ // Startup checkpoints; shared file with the AutoTesting runner.
+ private static readonly string DiagLogPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "gs-diag.log");
+ private static void DiagLog(string message)
+ {
+ try { System.IO.File.AppendAllText(DiagLogPath, $"{DateTime.UtcNow:HH:mm:ss.fff} [tid={Thread.CurrentThread.ManagedThreadId}] GS: {message}\n"); }
+ catch { /* best-effort */ }
+ }
+
[STAThread]
public static void Main()
{
+ Run(Environment.GetCommandLineArgs().Skip(1).ToList());
+ }
+
+ ///
+ /// Editor entry point body. fires after
+ /// InitializeComponent and before app.Run, giving the AutoTesting runner
+ /// access to the WPF Application + dispatcher.
+ ///
+ public static void Run(IList args, Action? appHosted = null)
+ {
+ DiagLog($"Run entered. args=[{string.Join(", ", args)}]");
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
EditorPath.EditorTitle = StrideGameStudio.EditorName;
@@ -87,8 +106,6 @@ public static void Main()
{
try
{
- // Environment.GetCommandLineArgs correctly process arguments regarding the presence of '\' and '"'
- var args = Environment.GetCommandLineArgs().Skip(1).ToList();
var startupSessionPath = StrideEditorSettings.StartupSession.GetValue();
var lastSessionPath = EditorSettings.ReloadLastSession.GetValue() ? mru.MostRecentlyUsedFiles.FirstOrDefault() : null;
var initialSessionPath = !UPath.IsNullOrEmpty(startupSessionPath) ? startupSessionPath : lastSessionPath?.FilePath;
@@ -159,7 +176,10 @@ public static void Main()
};
app.InitializeComponent();
+ appHosted?.Invoke(app, mainDispatcher);
+ DiagLog("calling app.Run");
app.Run();
+ DiagLog("app.Run returned");
}
renderDocManager?.RemoveHooks();
diff --git a/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs b/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs
index fd7a71ada8..808521c3ef 100644
--- a/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs
+++ b/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs
@@ -1,8 +1,11 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Windows;
+[assembly: InternalsVisibleTo("Stride.GameStudio.AutoTesting")]
+
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
diff --git a/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml b/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml
index ac2d088168..b4b55a439b 100644
--- a/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml
+++ b/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml
@@ -21,7 +21,7 @@
-
+
diff --git a/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs b/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs
index 7fc96cc4f2..4cfafc71e9 100644
--- a/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs
+++ b/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs
@@ -144,6 +144,8 @@ public DebuggingViewModel(GameStudioViewModel editor, IDebugService debugService
[NotNull]
public ICommandBase ResetOutputTitleCommand { get; }
+ internal Task<(bool Success, Process Process)> RunProjectAsync() => BuildProject(true);
+
///
public override void Destroy()
{
@@ -340,7 +342,7 @@ await ServiceProvider.Get()
try
{
// Build projects+assets (note: assets only would be enough)
- if (!await BuildProjectCore(false))
+ if (!(await BuildProjectCore(false)).Success)
{
return false;
}
@@ -356,19 +358,19 @@ await ServiceProvider.Get()
}
}
- private async Task BuildProject(bool startProject)
+ private async Task<(bool Success, Process Process)> BuildProject(bool startProject)
{
if (BuildInProgress)
- return false;
+ return (false, null);
try
{
BuildInProgress = true;
if (!await PrepareBuild())
- return false;
+ return (false, null);
var jobToken = editor.Status.NotifyBackgroundJobStarted("Building...", JobPriority.Compile);
- var result = false;
+ var result = (Success: false, Process: (Process)null);
try
{
return result = await BuildProjectCore(startProject);
@@ -376,7 +378,7 @@ private async Task BuildProject(bool startProject)
finally
{
editor.Status.NotifyBackgroundJobFinished(jobToken);
- editor.Status.PushDiscardableStatus(result ? "Build successful" : "Build failed");
+ editor.Status.PushDiscardableStatus(result.Success ? "Build successful" : "Build failed");
}
}
finally
@@ -391,8 +393,9 @@ private void RegisterBuildLogger(LoggerResult logger)
logger.MessageLogged += (sender, e) => Dispatcher.InvokeAsync(() => OutputTitle = outputTitleBase + '*');
}
- private async Task BuildProjectCore(bool startProject)
+ private async Task<(bool Success, Process Process)> BuildProjectCore(bool startProject)
{
+ Process startedProcess = null;
var logger = new LoggerResult();
RegisterBuildLogger(logger);
@@ -433,7 +436,7 @@ private async Task BuildProjectCore(bool startProject)
if (androidDevices.Length == 0)
{
logger.Error(Tr._p("Message", "No Android device found for execution."));
- return false;
+ return (false, null);
}
// On Android, directly install on device
@@ -463,14 +466,14 @@ private async Task BuildProjectCore(bool startProject)
default:
logger.Error(string.Format(Tr._p("Message", "Platform {0} isn't supported for execution."), Session.CurrentProject.Platform));
- return false;
+ return (false, null);
}
}
if (projectViewModel == null)
{
logger.Error(string.Format(Tr._p("Message", "Platform {0} isn't supported for execution."), Session.CurrentProject.Platform != PlatformType.Shared ? Session.CurrentProject.Platform : PlatformType.Windows));
- return false;
+ return (false, null);
}
// Build project
@@ -478,7 +481,7 @@ private async Task BuildProjectCore(bool startProject)
if (currentBuild == null)
{
logger.Error(string.Format(Tr._p("Message", "Unable to load and compile project {0}"), projectViewModel.ProjectPath));
- return false;
+ return (false, null);
}
var assemblyPath = currentBuild.AssemblyPath;
@@ -499,7 +502,7 @@ private async Task BuildProjectCore(bool startProject)
if (!File.Exists(assemblyPath))
{
logger.Error(string.Format(Tr._p("Message", "Unable to reach to output executable: {0}"), assemblyPath));
- return false;
+ return (false, null);
}
var process = new Process
{
@@ -509,6 +512,7 @@ private async Task BuildProjectCore(bool startProject)
}
};
process.Start();
+ startedProcess = process;
}
break;
case PlatformType.Android:
@@ -516,7 +520,7 @@ private async Task BuildProjectCore(bool startProject)
if (!buildTask.ResultsByTarget.TryGetValue("GetAndroidPackage", out var targetResult))
{
logger.Error(string.Format(Tr._p("Message", "Couldn't find Android package name for {0}."), Session.CurrentProject.Name));
- return false;
+ return (false, null);
}
var packageName = targetResult.Items[0].ItemSpec;
@@ -526,14 +530,14 @@ private async Task BuildProjectCore(bool startProject)
if (adbPath == null)
{
logger.Error(Tr._p("Message", @"Android tool ""adb"" couldn't found (no running process, in registry or on the PATH). Please add it to your PATH."));
- return false;
+ return (false, null);
}
// Run
var adbResult = await Task.Run(() => ShellHelper.RunProcessAndGetOutput(adbPath, $@"shell monkey -p {packageName} -c android.intent.category.LAUNCHER 1"));
if (adbResult.ExitCode != 0)
{
logger.Error(string.Format(Tr._p("Message", "Can't run Android app with adb: {0}"), string.Join(Environment.NewLine, adbResult.OutputErrors)));
- return false;
+ return (false, null);
}
break;
@@ -546,7 +550,7 @@ private async Task BuildProjectCore(bool startProject)
if (!File.Exists(assemblyPath))
{
logger.Error(Tr._p("Message", "Unable to reach to output executable: {0}"));
- return false;
+ return (false, null);
}
}
@@ -558,7 +562,7 @@ private async Task BuildProjectCore(bool startProject)
if (!prompt.AreCredentialsValid)
{
logger.Error(string.Format(Tr._p("Message", "No credentials provided. To allow deployment, add your credentials.")));
- return false;
+ return (false, null);
}
}
@@ -567,7 +571,7 @@ private async Task BuildProjectCore(bool startProject)
if (!launchApp)
{
logger.Error(string.Format(Tr._p("Message", "Unable to launch project {0}"), new UFile(assemblyPath).GetFileName()));
- return false;
+ return (false, null);
}
break;
@@ -583,7 +587,7 @@ private async Task BuildProjectCore(bool startProject)
await ServiceProvider.Get().MessageBoxAsync(string.Format(Tr._p("Message", "An exception occurred while compiling the project: {0}"), e.FormatSummary(true)), MessageBoxButton.OK, MessageBoxImage.Information);
}
- return !currentBuild.IsCanceled && !logger.HasErrors;
+ return (!currentBuild.IsCanceled && !logger.HasErrors, startedProcess);
}
private async Task PrepareBuild()
diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets
index 53f66f69ae..29847c147a 100644
--- a/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets
+++ b/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets
@@ -136,7 +136,7 @@
- false
+ false
false
diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets
index 2c9c609ec9..756176e531 100644
--- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets
+++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets
@@ -115,19 +115,19 @@
-
+
PreserveNewest
-
+
-
+
<_StrideDependencyNativeLib>
$([System.Text.RegularExpressions.Regex]::Match('%(Filename)', `(lib)*(.+)`).get_Groups().get_Item(2).ToString())
@@ -138,7 +138,7 @@
-
+
$(StrideMTouchExtras) -L"%24{ProjectDir}" @(_StrideDependencyNativeLib->'-l%(LibraryName) "%24{ProjectDir}/%(Filename)%(Extension)"',' ')
$(MtouchExtraArgs) --compiler=clang -cxx -gcc_flags '-lstdc++ $(MtouchExtraArgsLibs)'
diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props
index 40559ef618..3b3ba2e5d8 100644
--- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props
+++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props
@@ -11,4 +11,16 @@
true
+
+
+ $(TargetFramework)
+ $(StrideFrameworkWindows)
+ $(StrideFrameworkAndroid)
+ $(StrideFrameworkiOS)
+ $(StrideFrameworkmacOS)
+
+
\ No newline at end of file
diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets
index 73b579772c..77dcf8900a 100644
--- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets
+++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets
@@ -6,6 +6,18 @@
but BEFORE Sdk.targets.
-->
+
+
+ $(TargetFramework)
+ $(StrideFrameworkWindows)
+ $(StrideFrameworkAndroid)
+ $(StrideFrameworkiOS)
+ $(StrideFrameworkmacOS)
+
+
diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets
index 933cea7e5f..62ccbbf8c0 100644
--- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets
+++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets
@@ -21,20 +21,20 @@
- Direct3D11;Direct3D12;Vulkan
+ Direct3D11;Direct3D12;Vulkan
$(StrideGraphicsApis.Split(';', StringSplitOptions.RemoveEmptyEntries)[0])
- Direct3D11
- Vulkan
- Vulkan
+ Direct3D11
+ Vulkan
+ Vulkan
- false
- false
- false
+ false
+ false
+ false
$(StrideDefaultGraphicsApi)
@@ -84,8 +84,8 @@
- SDL
- $(StrideUI);WINFORMS;WPF
+ SDL
+ $(StrideUI);WINFORMS;WPF
$(DefineConstants);STRIDE_UI_SDL
$(DefineConstants);STRIDE_UI_WINFORMS
diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets
index c68f1167d6..ea5c83509d 100644
--- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets
+++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets
@@ -115,7 +115,7 @@
- <_StrideGraphicsApiDependentDisabledAtCurrentTF Condition="'$(TargetFramework)' == '$(StrideFrameworkiOS)' Or '$(TargetFramework)' == '$(StrideFrameworkAndroid)' Or '$(TargetFramework)' == '$(StrideFrameworkUWP)'">true
+ <_StrideGraphicsApiDependentDisabledAtCurrentTF Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkiOS)' Or '$(StrideTargetFramework)' == '$(StrideFrameworkAndroid)' Or '$(StrideTargetFramework)' == '$(StrideFrameworkUWP)'">true
- dotnet
- UWP
- Android
- iOS
+ dotnet
+ UWP
+ Android
+ iOS
diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets
index 8f5d8a7687..a0e1272762 100644
--- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets
+++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets
@@ -15,21 +15,21 @@
-
+
STRIDE_PLATFORM_DESKTOP
-
+
STRIDE_PLATFORM_MONO_MOBILE;STRIDE_PLATFORM_ANDROID
-
+
STRIDE_PLATFORM_MONO_MOBILE;STRIDE_PLATFORM_IOS
@@ -53,7 +53,7 @@
-
+
Library
21
@@ -61,14 +61,14 @@
$(AssemblyName)
-
+
true
-
+
True
None
-
+
False
SdkOnly
@@ -76,15 +76,15 @@
-
+
iPhone
Resources
-
-
-
-
+
+
+
+
diff --git a/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems b/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems
index 6a55e67415..e9df633792 100644
--- a/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems
+++ b/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems
@@ -20,9 +20,9 @@
$(IntermediateOutputPath)$(MSBuildProjectName).NuGetResolverEntryPoint$(DefaultLanguageSourceExtension)
- $(TargetFramework)
- $(TargetFramework)$(TargetPlatformVersion)
- STRIDE_NUGET_RESOLVER_UI;$(DefineConstants)
+ $(TargetFramework)
+ $(TargetFramework)$(TargetPlatformVersion)
+ STRIDE_NUGET_RESOLVER_UI;$(DefineConstants)
diff --git a/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs b/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs
index 09bde9dc30..4a3c534dbb 100644
--- a/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs
+++ b/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs
@@ -119,7 +119,12 @@ public static void SetupNuGet(List<(string targetFramework, string packageName,
var (request, result) = RestoreHelper.Restore(logger, nugetFramework, RuntimeInformation.RuntimeIdentifier, packageName, versionRange);
if (!result.Success)
{
- throw new InvalidOperationException("Could not restore NuGet packages");
+ var diagnostics = string.Join(Environment.NewLine,
+ logger.Logs
+ .Where(l => l.Level >= LogLevel.Warning)
+ .Select(l => $" [{l.Level}] {l.Message}"));
+ throw new InvalidOperationException(
+ $"Could not restore NuGet packages for {packageName} {packageVersion} ({targetFramework}).{Environment.NewLine}{diagnostics}");
}
// Build list of assemblies
diff --git a/samples/Tests/Comparator/ClaudeVisionFallback.cs b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs
similarity index 57%
rename from samples/Tests/Comparator/ClaudeVisionFallback.cs
rename to sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs
index 52130b7775..99cc38e92b 100644
--- a/samples/Tests/Comparator/ClaudeVisionFallback.cs
+++ b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs
@@ -1,19 +1,22 @@
-// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
+// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
-namespace Stride.SampleScreenshotComparator;
+namespace Stride.Tests.ScreenshotComparator;
///
-/// Calls Claude Haiku 4.5 vision with the baseline + capture and asks "is this the same scene?".
+/// Calls Claude Haiku 4.5 vision with the baseline(s) + capture and asks "is this the same scene?".
/// Used as a second-opinion fallback when LPIPS is over threshold but the test opted into
-/// claudeFallback. ANTHROPIC_API_KEY env var is required; if missing, the fallback fails
-/// closed (returns Pass=false) so the regression sticks.
+/// claudeFallback. When more than one baseline is provided they're framed as the
+/// acceptable variance range for the frame. ANTHROPIC_API_KEY env var is required; if missing,
+/// the fallback fails closed (returns Pass=false) so the regression sticks.
///
public static class ClaudeVisionFallback
{
@@ -25,24 +28,32 @@ public static class ClaudeVisionFallback
public readonly record struct Verdict(bool Pass, string Reason);
- public static Verdict Compare(string baselinePath, string capturePath, string? extraHint)
+ /// Single-baseline overload — back-compat shim around the multi-baseline form.
+ public static Verdict Compare(string baselinePath, string capturePath, ComparisonPrompt prompt)
+ => Compare(new[] { baselinePath }, capturePath, prompt);
+
+ public static Verdict Compare(IReadOnlyList baselinePaths, string capturePath, ComparisonPrompt prompt)
{
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
if (string.IsNullOrEmpty(apiKey))
return new Verdict(false, "ANTHROPIC_API_KEY not set");
+ if (baselinePaths.Count == 0)
+ return new Verdict(false, "no baselines provided");
- var baselineB64 = Convert.ToBase64String(File.ReadAllBytes(baselinePath));
var captureB64 = Convert.ToBase64String(File.ReadAllBytes(capturePath));
+ var promptText = prompt.Build(baselinePaths.Count);
- var prompt = "Compare these two game screenshots — first is the BASELINE (expected), " +
- "second is the CAPTURE (this run). Reply YES if they show the same UI state " +
- "and same visible content (same text, buttons, characters, scene layout). " +
- "Treat noise (particle positions, animation cycle phase, lighting flicker) " +
- "as acceptable. Reply NO if there is a meaningful regression (different UI " +
- "page, missing element, wrong text, different scene). " +
- "Format: \"YES: \" or \"NO: \".";
- if (!string.IsNullOrEmpty(extraHint))
- prompt += " Additional context for this specific frame: " + extraHint;
+ var content = new List