diff --git a/.gitattributes b/.gitattributes index 69d610003e..74fb2562c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -37,6 +37,7 @@ glslangValidator filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.tar.gz filter=lfs diff=lfs merge=lfs -text *.vsix filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text # 3D formats *.max filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/test-samples-baselines.yml b/.github/workflows/test-samples-baselines.yml new file mode 100644 index 0000000000..510ce1192a --- /dev/null +++ b/.github/workflows/test-samples-baselines.yml @@ -0,0 +1,96 @@ +name: Test Samples (Update Baselines) + +on: + workflow_dispatch: + inputs: + run_id: + description: "Workflow run ID to pull capture artifacts from (the daily test-samples-screenshots run whose captures should become the new baselines). Empty = latest successful run." + type: string + default: '' + branch_name: + description: "Branch name to push the baseline update to (auto-generated if empty)." + type: string + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + Update: + name: Refresh tests/Stride.Samples.Tests baselines + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: true + # Need full history for the auto-generated branch name to be stable. + fetch-depth: 0 + + - name: Resolve target run id + id: resolve + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ -n "${{ github.event.inputs.run_id }}" ]; then + echo "run_id=${{ github.event.inputs.run_id }}" >> "$GITHUB_OUTPUT" + else + # Latest successful run of the daily capture workflow. + run_id=$(gh run list \ + --workflow=test-samples-screenshots.yml \ + --status=success \ + --limit=1 \ + --json databaseId \ + --jq '.[0].databaseId') + if [ -z "$run_id" ]; then + echo "No successful test-samples-screenshots run found." >&2 + exit 1 + fi + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + fi + + - name: Download D3D11 capture artifact from run ${{ steps.resolve.outputs.run_id }} + # Baselines are sourced from D3D11 only — that's the gating matrix entry. D3D12/Vulkan + # are best-effort; their captures should not become the baseline reference. + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p captures + gh run download ${{ steps.resolve.outputs.run_id }} \ + --name screenshots-Direct3D11 \ + --dir ./captures + # Layout: captures/screenshot-out//screenshots/.png + find captures -maxdepth 4 -type d | head -40 + + - name: Refresh tests/Stride.Samples.Tests//.png from captures + run: | + set -e + changed=0 + for sample_dir in captures/screenshot-out/*/; do + sample=$(basename "$sample_dir") + src="$sample_dir/screenshots" + [ -d "$src" ] || continue + dest="tests/Stride.Samples.Tests/$sample" + mkdir -p "$dest" + for png in "$src"/*.png; do + [ -f "$png" ] || continue + cp "$png" "$dest/" + changed=$((changed + 1)) + done + done + echo "Replaced $changed baseline PNG(s)." + + - name: Open PR with refreshed baselines + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "tests/Stride.Samples.Tests: refresh screenshot baselines from run ${{ steps.resolve.outputs.run_id }}" + title: "Refresh sample screenshot baselines (run ${{ steps.resolve.outputs.run_id }})" + body: | + Automated baseline refresh from the captures of `test-samples-screenshots.yml` + run [#${{ steps.resolve.outputs.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ steps.resolve.outputs.run_id }}). + + Diff this PR against the previous baselines visually before merging — every PNG + replacement here becomes the new "approved" reference for daily comparison. + branch: ${{ github.event.inputs.branch_name || format('baselines/refresh-{0}', steps.resolve.outputs.run_id) }} + delete-branch: true + add-paths: tests/Stride.Samples.Tests/**/*.png diff --git a/.github/workflows/test-samples-screenshots.yml b/.github/workflows/test-samples-screenshots.yml new file mode 100644 index 0000000000..832eaa672f --- /dev/null +++ b/.github/workflows/test-samples-screenshots.yml @@ -0,0 +1,168 @@ +name: Test Samples (Screenshots) + +on: + workflow_dispatch: + inputs: + build-type: + description: Build + default: Debug + type: choice + options: + - Debug + - Release + schedule: + # Daily at 04:00 UTC. + - cron: '0 4 * * *' + +concurrency: + group: test-samples-screenshots-${{ github.ref }} + cancel-in-progress: true + +jobs: + Run: + name: Capture & compare all samples (${{ matrix.graphics-api }}, ${{ github.event.inputs.build-type || 'Debug' }}) + # Single job per graphics API: the harness + orchestrator + comparator are sample-agnostic, so a + # matrix-per-sample would build the engine 15× for the same artifacts. Sequential capture + # via xunit's DisableParallelization keeps total compute low (~1× engine build + ~5 min of + # actual sample runs) at comparable wall-clock to a 15-parallel matrix. Per-API parallelism + # is unavoidable (each API needs its own engine build). + strategy: + fail-fast: false + matrix: + graphics-api: [Direct3D11, Direct3D12, Vulkan] + runs-on: windows-2025-vs2026 + # TODO: drop continue-on-error once D3D12 and Vulkan captures are stable. + continue-on-error: ${{ matrix.graphics-api != 'Direct3D11' }} + env: + # Software rendering is the default in the autotesting harness; set STRIDE_TESTS_GPU=1 to opt into the GPU. + STRIDE_TESTS_RENDERDOC: "error" + 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 declares `bin/packages` as the `stride-local` source. NuGet validates every + # source path at restore time; on a fresh CI checkout that dir doesn't exist yet (it gets + # populated by Stride's auto-pack-deploy on first build), so we materialize it as 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 at test runtime calls Settings.LoadDefaultSettings(null) which only loads + # user + machine configs, NOT the workspace's nuget.config (no walkup with null root). + # Locally this works because the dev's user config has Stride.AutoPackDeploy's + # %LocalAppData%\Stride\NugetDev as a source. CI's runneradmin user has no such config, + # so add bin/packages explicitly to the user config. --configfile forces the write to + # the user config rather than walking up to the workspace's nuget.config (which would + # conflict with the existing 'stride-local' source declared there). + - 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" + + # Stride.Games.AutoTesting must be built explicitly so its auto-pack-deploy lands the .nupkg + # in bin/packages — the sample projects consume it via , not via the + # ProjectReference graph the test project would otherwise transitively pull. The engine + # is built with StrideGraphicsApis pinned to the matrix entry so the resulting nupkg only + # contains binaries for that API; the regenerated samples then link against those. + - name: Build harness assembly (packs Stride.Games.AutoTesting into bin/packages) + run: | + dotnet build sources\engine\Stride.Games.AutoTesting\Stride.Games.AutoTesting.csproj ` + -p:StrideNativeBuildMode=Clang ` + -nr:false -v:m -p:WarningLevel=0 ` + -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }} ` + -p:StrideGraphicsApi=${{ matrix.graphics-api }} ` + -p:StrideGraphicsApis=${{ matrix.graphics-api }} + + # Install Stride.Dependencies.Lavapipe and register its ICD with the Vulkan loader + # so the samples' Vulkan path resolves a software driver on the CI runner (no GPU). + # On Windows the loader reads HKLM\SOFTWARE\Khronos\Vulkan\Drivers — VK_DRIVER_FILES + # env var alone isn't reliable across runner Vulkan SDK versions. Mirrors the + # SwiftShader registration in test-windows-game.yml. + - name: Install Lavapipe and register its Vulkan ICD (Vulkan only) + if: matrix.graphics-api == 'Vulkan' + shell: pwsh + run: | + $tmpDir = "${{ runner.temp }}\lavapipe-fetch" + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + Set-Content -Path "$tmpDir\LavapipeFetch.csproj" -Value 'net10.0' + dotnet add "$tmpDir\LavapipeFetch.csproj" package Stride.Dependencies.Lavapipe + $icd = Get-ChildItem -Recurse -Path "$env:USERPROFILE\.nuget\packages\stride.dependencies.lavapipe" -Filter "lvp_icd*.json" -ErrorAction SilentlyContinue | Where-Object { $_.DirectoryName -match 'win-x64' } | Select-Object -First 1 + if (-not $icd) { throw "Lavapipe ICD not found in NuGet cache after restore." } + New-Item -Path "HKLM:\SOFTWARE\Khronos\Vulkan\Drivers" -Force | Out-Null + New-ItemProperty -Path "HKLM:\SOFTWARE\Khronos\Vulkan\Drivers" -Name $icd.FullName -Value 0 -PropertyType DWord -Force | Out-Null + Write-Host "Registered Lavapipe ICD: $($icd.FullName)" + + # Builds the test project plus its transitive ProjectReferences. StrideGraphicsApi (singular) + # selects the test SDK output path; StrideGraphicsApis (plural) keeps the engine deps aligned + # with the harness build above. + - name: Build test project + run: | + dotnet build samples\Tests\Stride.Samples.Tests.csproj ` + -p:StrideNativeBuildMode=Clang ` + -nr:false -v:m -p:WarningLevel=0 ` + -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }} ` + -p:StrideGraphicsApi=${{ matrix.graphics-api }} ` + -p:StrideGraphicsApis=${{ matrix.graphics-api }} + + - name: Run sample screenshot tests + env: + # Comparator opts a few non-deterministic tests (ParticlesSample, SpriteStudioDemo, + # AnimatedModel) into a Claude vision second-opinion when LPIPS is over threshold. + # Key is bound to a budget-capped Anthropic workspace; the fallback only runs on + # frames that already failed LPIPS so cost is bounded even when noisy. + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + dotnet test samples\Tests\Stride.Samples.Tests.csproj ` + --no-build ` + --filter "FullyQualifiedName~Stride.Samples.Tests.SampleScreenshotTests" ` + --logger "trx;LogFileName=screenshots.trx" ` + --results-directory TestResults ` + -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }} ` + -p:StrideGraphicsApi=${{ matrix.graphics-api }} + + - name: Publish test report + if: always() + uses: phoenix-actions/test-reporting@v15 + with: + name: 'Sample screenshot regression (${{ matrix.graphics-api }})' + path: TestResults/*.trx + reporter: dotnet-trx + output-to: step-summary + list-tests: 'failed' + + - name: Upload test artifacts (captures + done.json + TRX) + if: always() + uses: actions/upload-artifact@v4 + with: + name: screenshots-${{ matrix.graphics-api }} + path: | + screenshot-out/ + TestResults/ + if-no-files-found: warn + + - name: Upload crash dumps + if: always() + uses: actions/upload-artifact@v4 + with: + name: crash-dumps-${{ matrix.graphics-api }} + path: crash-dumps/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index ba22695add..0e2b546ccf 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,11 @@ x64Release *.opendb screenshots samplesGenerated +screenshot-regression-out +screenshot-out +# 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 # VS Code files .vscode/* diff --git a/build/Stride.sln b/build/Stride.sln index 01883bf7ee..ef0a3f6140 100644 --- a/build/Stride.sln +++ b/build/Stride.sln @@ -188,12 +188,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Graphics.Tests.10_0. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Graphics.Tests.11_0.Windows", "..\sources\engine\Stride.Graphics.Tests.11_0\Stride.Graphics.Tests.11_0.Windows.csproj", "{7CA99C7B-E3A2-4DE6-9D6C-314AE39BBBB7}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.SamplesTestServer", "..\sources\tools\Stride.SamplesTestServer\Stride.SamplesTestServer.csproj", "{75D71310-ECF7-4592-9E35-3FE540040982}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Particles", "..\sources\engine\Stride.Particles\Stride.Particles.csproj", "{F32FDA80-B6DD-47A8-8681-437E2C0D3F31}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Games.Testing", "..\sources\engine\Stride.Games.Testing\Stride.Games.Testing.csproj", "{B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Native", "..\sources\engine\Stride.Native\Stride.Native.csproj", "{1DBBC150-F085-43EF-B41D-27C72D133770}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Assets.Tests2", "..\sources\engine\Stride.Assets.Tests2\Stride.Assets.Tests2.csproj", "{370ADF53-DFFA-461E-B72A-1302C0A0DE00}" @@ -978,18 +974,6 @@ Global {7CA99C7B-E3A2-4DE6-9D6C-314AE39BBBB7}.Release|Mixed Platforms.Build.0 = Release|Any CPU {7CA99C7B-E3A2-4DE6-9D6C-314AE39BBBB7}.Release|Win32.ActiveCfg = Release|Any CPU {7CA99C7B-E3A2-4DE6-9D6C-314AE39BBBB7}.Release|Win32.Build.0 = Release|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Debug|Any CPU.Build.0 = Debug|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Debug|Win32.ActiveCfg = Debug|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Debug|Win32.Build.0 = Debug|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Release|Any CPU.ActiveCfg = Release|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Release|Any CPU.Build.0 = Release|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Release|Win32.ActiveCfg = Release|Any CPU - {75D71310-ECF7-4592-9E35-3FE540040982}.Release|Win32.Build.0 = Release|Any CPU {F32FDA80-B6DD-47A8-8681-437E2C0D3F31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F32FDA80-B6DD-47A8-8681-437E2C0D3F31}.Debug|Any CPU.Build.0 = Debug|Any CPU {F32FDA80-B6DD-47A8-8681-437E2C0D3F31}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -1002,18 +986,6 @@ Global {F32FDA80-B6DD-47A8-8681-437E2C0D3F31}.Release|Mixed Platforms.Build.0 = Release|Any CPU {F32FDA80-B6DD-47A8-8681-437E2C0D3F31}.Release|Win32.ActiveCfg = Release|Any CPU {F32FDA80-B6DD-47A8-8681-437E2C0D3F31}.Release|Win32.Build.0 = Release|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Debug|Win32.ActiveCfg = Debug|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Debug|Win32.Build.0 = Debug|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Release|Any CPU.Build.0 = Release|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Release|Win32.ActiveCfg = Release|Any CPU - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9}.Release|Win32.Build.0 = Release|Any CPU {1DBBC150-F085-43EF-B41D-27C72D133770}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1DBBC150-F085-43EF-B41D-27C72D133770}.Debug|Any CPU.Build.0 = Debug|Any CPU {1DBBC150-F085-43EF-B41D-27C72D133770}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -1602,9 +1574,7 @@ Global {9DE0AA56-0DE7-4ADC-BAAC-CD38B7139EBC} = {A7ED9F01-7D78-4381-90A6-D50E51C17250} {570B0FF9-246F-4C6C-8384-F6BE1887A4A9} = {A7ED9F01-7D78-4381-90A6-D50E51C17250} {7CA99C7B-E3A2-4DE6-9D6C-314AE39BBBB7} = {A7ED9F01-7D78-4381-90A6-D50E51C17250} - {75D71310-ECF7-4592-9E35-3FE540040982} = {1AE1AC60-5D2F-4CA7-AE20-888F44551185} {F32FDA80-B6DD-47A8-8681-437E2C0D3F31} = {4C142567-C42B-40F5-B092-798882190209} - {B84ECB15-5E3F-4BD1-AB87-333BAE9B70F9} = {A7ED9F01-7D78-4381-90A6-D50E51C17250} {1DBBC150-F085-43EF-B41D-27C72D133770} = {4C142567-C42B-40F5-B092-798882190209} {370ADF53-DFFA-461E-B72A-1302C0A0DE00} = {A47B451D-3162-410F-BAF7-C650C4B7A4B0} {33CC6216-3F30-4B5A-BB29-C5B47EFFA713} = {A7ED9F01-7D78-4381-90A6-D50E51C17250} diff --git a/nuget.config b/nuget.config index 44a3be800a..ff5f2bd7cb 100644 --- a/nuget.config +++ b/nuget.config @@ -2,16 +2,23 @@ - - + + + + + + + + + + + + + - - diff --git a/samples/Directory.Build.targets b/samples/Directory.Build.targets new file mode 100644 index 0000000000..2d1a9fb10a --- /dev/null +++ b/samples/Directory.Build.targets @@ -0,0 +1,44 @@ + + + + $(DefineConstants);STRIDE_AUTOTESTING + + + + + + + + + + + + + <_StrideAutoTestingBootstrapContent> +using System.Runtime.CompilerServices%3B +using Stride.Games.AutoTesting%3B + +internal static class _AutoTestingHarnessBootstrap +{ + [ModuleInitializer] + public static void ForceLoadHarness() => AutoTestingBootstrap.EnsureLoaded()%3B +} + + + + + diff --git a/samples/Graphics/AnimatedModel/Assets/Shared/Scene.sdscene b/samples/Graphics/AnimatedModel/Assets/Shared/Scene.sdscene index c6f6c4c1c0..38b78492ba 100644 --- a/samples/Graphics/AnimatedModel/Assets/Shared/Scene.sdscene +++ b/samples/Graphics/AnimatedModel/Assets/Shared/Scene.sdscene @@ -174,7 +174,7 @@ Hierarchy: 585d445ca1707649a8887bbb03ccca41: !UIComponent Id: 5c445d58-70a1-4976-a888-7bbb03ccca41 Page: 0c8d34ec-5a72-42f6-9203-6096216ec54a:Page - Resolution: {X: 1136.0, Y: 640.0, Z: 1000.0} + Resolution: {X: 640.0, Y: 1136.0, Z: 1000.0} Size: {X: 1.0, Y: 1.0, Z: 1.0} ResolutionStretch: FixedWidthFixedHeight RenderGroup: Group1 diff --git a/samples/Graphics/SpriteFonts/SpriteFonts.Game/FontRenderer.cs b/samples/Graphics/SpriteFonts/SpriteFonts.Game/FontRenderer.cs index b4643ce023..75d4cc6c10 100644 --- a/samples/Graphics/SpriteFonts/SpriteFonts.Game/FontRenderer.cs +++ b/samples/Graphics/SpriteFonts/SpriteFonts.Game/FontRenderer.cs @@ -355,7 +355,8 @@ private void UpdateInput() } else if (input.IsKeyPressed(Keys.Left) || input.IsKeyPressed(Keys.Right)) { - currentTime = 0; + // Skip the fade-in when paused so the new screen is immediately readable. + currentTime = isPlaying ? 0 : FadeInDuration; currentScreenIndex = (currentScreenIndex + (input.IsKeyPressed(Keys.Left) ? -1 : +1) + screenRenderers.Count) % screenRenderers.Count; } } @@ -377,9 +378,6 @@ private void UpdateCurrentScreenIndex() /// private void UpdateAnimatedFontParameters() { - if (!isPlaying) - return; - animatedFontAlpha = GetVaryingValue(1.6f * currentTime); animatedFontRotation = 2f * currentTime * (float)Math.PI; animatedFontPosition = GetVirtualPosition(0.5f, 0.65f) + 160 * new Vector2(1.5f * (float)Math.Cos(1.5f * currentTime), (float)Math.Sin(1.5f * currentTime)); diff --git a/samples/Tests/Comparator/ClaudeVisionFallback.cs b/samples/Tests/Comparator/ClaudeVisionFallback.cs new file mode 100644 index 0000000000..52130b7775 --- /dev/null +++ b/samples/Tests/Comparator/ClaudeVisionFallback.cs @@ -0,0 +1,98 @@ +// 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; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace Stride.SampleScreenshotComparator; + +/// +/// Calls Claude Haiku 4.5 vision with the baseline + 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. +/// +public static class ClaudeVisionFallback +{ + private const string Model = "claude-haiku-4-5"; + private const string Endpoint = "https://api.anthropic.com/v1/messages"; + private const string ApiVersion = "2023-06-01"; + + private static readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(60) }; + + public readonly record struct Verdict(bool Pass, string Reason); + + public static Verdict Compare(string baselinePath, string capturePath, string? extraHint) + { + var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); + if (string.IsNullOrEmpty(apiKey)) + return new Verdict(false, "ANTHROPIC_API_KEY not set"); + + var baselineB64 = Convert.ToBase64String(File.ReadAllBytes(baselinePath)); + var captureB64 = Convert.ToBase64String(File.ReadAllBytes(capturePath)); + + 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 body = JsonSerializer.Serialize(new + { + model = Model, + max_tokens = 80, + temperature = 0.0, + messages = new[] + { + new + { + role = "user", + content = new object[] + { + new { type = "text", text = "BASELINE:" }, + new { type = "image", source = new { type = "base64", media_type = "image/png", data = baselineB64 } }, + new { type = "text", text = "CAPTURE:" }, + new { type = "image", source = new { type = "base64", media_type = "image/png", data = captureB64 } }, + new { type = "text", text = prompt }, + }, + }, + }, + }); + + try + { + using var req = new HttpRequestMessage(HttpMethod.Post, Endpoint) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + req.Headers.Add("x-api-key", apiKey); + req.Headers.Add("anthropic-version", ApiVersion); + + using var resp = http.Send(req); + var respBody = resp.Content.ReadAsStringAsync().Result; + if (!resp.IsSuccessStatusCode) + return new Verdict(false, $"claude api {(int)resp.StatusCode}: {Truncate(respBody, 200)}"); + + using var doc = JsonDocument.Parse(respBody); + // Response shape: { content: [{ type: "text", text: "YES: ..." | "NO: ..." }] } + var text = doc.RootElement.GetProperty("content")[0].GetProperty("text").GetString() ?? ""; + text = text.Trim(); + // Accept "YES" or "NO" prefix (case-insensitive). + var pass = text.StartsWith("YES", StringComparison.OrdinalIgnoreCase); + return new Verdict(pass, text); + } + catch (Exception ex) + { + return new Verdict(false, $"claude error: {ex.Message}"); + } + } + + private static string Truncate(string s, int max) => s.Length <= max ? s : s.Substring(0, max) + "…"; +} diff --git a/samples/Tests/Comparator/ScreenshotComparator.cs b/samples/Tests/Comparator/ScreenshotComparator.cs new file mode 100644 index 0000000000..4b52771bb8 --- /dev/null +++ b/samples/Tests/Comparator/ScreenshotComparator.cs @@ -0,0 +1,231 @@ +// 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.Text.Json; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace Stride.SampleScreenshotComparator; + +/// +/// Pixel-perceptual screenshot comparator. Reads new captures from and +/// matching baselines from , scores each pair with LPIPS-AlexNet +/// (via ONNX Runtime), and returns one per (sample, frame) +/// pair with the distance and a coarse status (ok / drift / new / missing). +/// +/// Layout assumed: +/// newDir <dir>/<sample>/screenshots/<frame>.png +/// baselineDir <dir>/<sample>/<frame>.png +/// +public static class ScreenshotComparator +{ + public const float DefaultThreshold = 0.05f; + + /// + /// LPIPS canonical eval resolution. Resizing to a square here also dodges a NaN-on-tall-aspect + /// issue with the ONNX-exported AlexNet at native portrait sizes (640×1136 → NaN; 256×256 → 0) + /// — the L2-normalize step loses numerical safety through the dynamo export. + /// + private const int InputSize = 256; + + /// + /// Compare every /<sample>/screenshots/<frame>.png against the matching + /// baseline. restricts to one sample by name. Per-frame thresholds + /// emitted by the harness in done.json win over . Pass + /// to override where lpips_alex.onnx is read from (default looks in + /// the executing assembly's models/ sibling — works when the file is CopyToOutputDirectory'd + /// into the consumer's bin). + /// + public static List Compare(string newDir, string baselineDir, string? sampleFilter = null, float defaultThreshold = DefaultThreshold, string? modelPath = null) + { + modelPath ??= Path.Combine(AppContext.BaseDirectory, "models", "lpips_alex.onnx"); + if (!File.Exists(modelPath)) + throw new FileNotFoundException($"LPIPS model not found at {modelPath}", modelPath); + + using var session = new InferenceSession(modelPath, new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL }); + + var results = new List(); + if (!Directory.Exists(newDir)) + throw new DirectoryNotFoundException($"--new dir not found: {newDir}"); + + foreach (var sampleDir in Directory.EnumerateDirectories(newDir)) + { + var sample = Path.GetFileName(sampleDir); + if (sampleFilter is not null && !string.Equals(sample, sampleFilter, StringComparison.OrdinalIgnoreCase)) + continue; + var screenshotsDir = Path.Combine(sampleDir, "screenshots"); + if (!Directory.Exists(screenshotsDir)) + continue; + + // Per-frame metadata emitted by the harness in done.json (threshold + optional Claude + // fallback). Falls back to defaultThreshold for samples whose harness predates the schema. + var perFrameMetadata = LoadPerFrameMetadata(Path.Combine(sampleDir, "done.json")); + + foreach (var newPng in Directory.EnumerateFiles(screenshotsDir, "*.png")) + { + var frame = Path.GetFileNameWithoutExtension(newPng); + var baselinePng = Path.Combine(baselineDir, sample, frame + ".png"); + perFrameMetadata.TryGetValue(frame, out var meta); + var frameThreshold = meta.Threshold ?? defaultThreshold; + + if (!File.Exists(baselinePng)) + { + results.Add(new ComparisonResult(sample, frame, null, frameThreshold, "new", "no baseline yet")); + continue; + } + + float distance; + try + { + distance = ComputeLpips(session, baselinePng, newPng); + } + catch (Exception ex) + { + results.Add(new ComparisonResult(sample, frame, null, frameThreshold, "error", ex.Message)); + continue; + } + + if (distance < frameThreshold) + { + results.Add(new ComparisonResult(sample, frame, distance, frameThreshold, "ok", null)); + continue; + } + + if (meta.ClaudeFallbackEnabled) + { + var verdict = ClaudeVisionFallback.Compare(baselinePng, newPng, meta.ClaudeFallbackHint); + var detail = $"lpips drift; claude: {verdict.Reason}"; + results.Add(new ComparisonResult(sample, frame, distance, frameThreshold, + verdict.Pass ? "ok-via-claude" : "drift", detail)); + continue; + } + + results.Add(new ComparisonResult(sample, frame, distance, frameThreshold, "drift", null)); + } + } + + // Walk baselines that have no matching new capture (missing — capture probably failed). + if (Directory.Exists(baselineDir)) + { + foreach (var sampleDir in Directory.EnumerateDirectories(baselineDir)) + { + var sample = Path.GetFileName(sampleDir); + if (sampleFilter is not null && !string.Equals(sample, sampleFilter, StringComparison.OrdinalIgnoreCase)) + continue; + foreach (var baselinePng in Directory.EnumerateFiles(sampleDir, "*.png")) + { + var frame = Path.GetFileNameWithoutExtension(baselinePng); + var newPng = Path.Combine(newDir, sample, "screenshots", frame + ".png"); + if (File.Exists(newPng)) + continue; + results.Add(new ComparisonResult(sample, frame, null, defaultThreshold, "missing", "no capture for this baseline")); + } + } + } + + results.Sort((a, b) => + { + var c = string.Compare(a.Sample, b.Sample, StringComparison.Ordinal); + return c != 0 ? c : string.Compare(a.Frame, b.Frame, StringComparison.Ordinal); + }); + return results; + } + + private static float ComputeLpips(InferenceSession session, string pathA, string pathB) + { + using var imgA = Image.Load(pathA); + using var imgB = Image.Load(pathB); + imgA.Mutate(c => c.Resize(InputSize, InputSize)); + imgB.Mutate(c => c.Resize(InputSize, InputSize)); + + var tensorA = ToTensor(imgA); + var tensorB = ToTensor(imgB); + + var inputs = new List + { + NamedOnnxValue.CreateFromTensor("a", tensorA), + NamedOnnxValue.CreateFromTensor("b", tensorB), + }; + + using var results = session.Run(inputs); + var output = (DenseTensor)results[0].Value; + return output[0]; + } + + /// PNG bytes → NCHW float32 in [-1, 1], matching the preprocessing the lpips Python lib does. + private static DenseTensor ToTensor(Image img) + { + int w = img.Width, h = img.Height; + var data = new float[3 * h * w]; + img.ProcessPixelRows(rows => + { + for (int y = 0; y < h; y++) + { + var row = rows.GetRowSpan(y); + for (int x = 0; x < w; x++) + { + var p = row[x]; + var idx = y * w + x; + data[0 * h * w + idx] = p.R / 127.5f - 1f; + data[1 * h * w + idx] = p.G / 127.5f - 1f; + data[2 * h * w + idx] = p.B / 127.5f - 1f; + } + } + }); + return new DenseTensor(data, new[] { 1, 3, h, w }); + } + + /// + /// Reads done.json for one sample and returns a frame-name → threshold map. The harness + /// emits screenshots: [{name, threshold}]; older runs may have screenshots: [string] + /// in which case we return an empty map and let the caller fall back to the default threshold. + /// + private static Dictionary LoadPerFrameMetadata(string donePath) + { + var result = new Dictionary(StringComparer.Ordinal); + if (!File.Exists(donePath)) + return result; + try + { + using var stream = File.OpenRead(donePath); + using var doc = JsonDocument.Parse(stream); + if (!doc.RootElement.TryGetProperty("screenshots", out var arr) || arr.ValueKind != JsonValueKind.Array) + return result; + foreach (var entry in arr.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) continue; + if (!entry.TryGetProperty("Name", out var nameEl) && !entry.TryGetProperty("name", out nameEl)) continue; + var name = nameEl.GetString(); + if (name is null) continue; + float? threshold = null; + if (entry.TryGetProperty("Threshold", out var thrEl) || entry.TryGetProperty("threshold", out thrEl)) + { + if (thrEl.ValueKind == JsonValueKind.Number) + threshold = thrEl.GetSingle(); + } + // ClaudeFallback: null absent / true (generic prompt) / string (extra guidance). + bool fallbackEnabled = false; + string? fallbackHint = null; + if (entry.TryGetProperty("ClaudeFallback", out var fbEl) || entry.TryGetProperty("claudeFallback", out fbEl)) + { + if (fbEl.ValueKind == JsonValueKind.True) fallbackEnabled = true; + else if (fbEl.ValueKind == JsonValueKind.String) { fallbackEnabled = true; fallbackHint = fbEl.GetString(); } + } + result[name] = new FrameMetadata(threshold, fallbackEnabled, fallbackHint); + } + } + catch + { + // Malformed done.json — fall back to default threshold for everything in this sample. + } + return result; + } + + private readonly record struct FrameMetadata(float? Threshold, bool ClaudeFallbackEnabled, string? ClaudeFallbackHint); +} + +/// One row per (sample, frame) compared. is null for status="new" / "missing" / "error". +public sealed record ComparisonResult(string Sample, string Frame, float? Lpips, float Threshold, string Status, string? Detail); diff --git a/samples/Tests/Comparator/models/README.md b/samples/Tests/Comparator/models/README.md new file mode 100644 index 0000000000..46a7f5d176 --- /dev/null +++ b/samples/Tests/Comparator/models/README.md @@ -0,0 +1,25 @@ +# LPIPS-AlexNet ONNX model + +`lpips_alex.onnx` is a one-shot export of the [LPIPS](https://github.com/richzhang/PerceptualSimilarity) +perceptual-distance network (AlexNet backbone + LPIPS head). The Comparator references it via +ONNX Runtime to score screenshot pairs. Once exported, it never needs regenerating during normal +operation — the weights are static and content-independent. + +Re-export only when you want to: +- swap the backbone (`alex` → `vgg`, ~4× slower but slightly better perceptual fidelity) +- adopt newer LPIPS head weights from the upstream Python package +- bump the ONNX opset + +## How to regenerate + +Requires Python 3.10+ on a path short enough that pip doesn't trip on Windows long-path limits +(`C:\tmp\lpips-export` works; deep paths under `AppData\Local\Packages\…` don't). + +```bash +python -m venv venv +./venv/Scripts/python.exe -m pip install torch torchvision lpips onnx onnxruntime onnxscript +PYTHONIOENCODING=utf-8 ./venv/Scripts/python.exe export.py +``` + +The script also runs an inline parity check (PyTorch vs ONNX Runtime on a randomized input) +and prints the delta — should be `~1e-8`. If it isn't, something regressed in the export path. diff --git a/samples/Tests/Comparator/models/export.py b/samples/Tests/Comparator/models/export.py new file mode 100644 index 0000000000..98eaf72236 --- /dev/null +++ b/samples/Tests/Comparator/models/export.py @@ -0,0 +1,58 @@ +""" +One-shot exporter: produce lpips_alex.onnx from the lpips Python lib. +Run from this dir's venv: ./venv/Scripts/python.exe export.py +""" +import torch +import lpips + +# 'alex' = AlexNet backbone, much faster than VGG, sufficient quality for screenshot regression. +model = lpips.LPIPS(net='alex', spatial=False, verbose=False) +model.eval() + +# Wrap so the ONNX inputs are the two raw [-1, 1]-normalized RGB tensors and the output is a scalar distance. +class _Wrapper(torch.nn.Module): + def __init__(self, m): super().__init__(); self.m = m + def forward(self, a, b): + # m(a, b) returns a (1,1,1,1) tensor; squeeze to scalar. + return self.m(a, b).reshape(-1) + +w = _Wrapper(model).eval() + +# Dummy inputs at 256x256; the model is fully convolutional so any HxW works at runtime. +a = torch.zeros(1, 3, 256, 256) +b = torch.zeros(1, 3, 256, 256) + +torch.onnx.export( + w, + (a, b), + 'lpips_alex_external.onnx', + input_names=['a', 'b'], + output_names=['distance'], + dynamic_axes={ + 'a': {0: 'batch', 2: 'height', 3: 'width'}, + 'b': {0: 'batch', 2: 'height', 3: 'width'}, + 'distance': {0: 'batch'}, + }, + opset_version=17, +) +# torch.onnx.export emits external data by default; re-save with everything inlined so the .NET +# comparator only needs the single .onnx file. +import onnx +m = onnx.load('lpips_alex_external.onnx', load_external_data=True) +onnx.save(m, 'lpips_alex.onnx', save_as_external_data=False) +import os +os.remove('lpips_alex_external.onnx') +if os.path.exists('lpips_alex_external.onnx.data'): + os.remove('lpips_alex_external.onnx.data') +print('exported lpips_alex.onnx') + +# Sanity: round-trip ORT vs torch on a non-trivial input. +import numpy as np +import onnxruntime as ort +torch.manual_seed(0) +a = torch.rand(1, 3, 256, 256) * 2 - 1 +b = torch.rand(1, 3, 256, 256) * 2 - 1 +torch_d = float(w(a, b)[0]) +sess = ort.InferenceSession('lpips_alex.onnx', providers=['CPUExecutionProvider']) +ort_d = sess.run(None, {'a': a.numpy(), 'b': b.numpy()})[0][0] +print(f' torch={torch_d:.6f} ort={ort_d:.6f} delta={abs(torch_d-ort_d):.2e}') diff --git a/samples/Tests/Comparator/models/lpips_alex.onnx b/samples/Tests/Comparator/models/lpips_alex.onnx new file mode 100644 index 0000000000..ab93b81a28 --- /dev/null +++ b/samples/Tests/Comparator/models/lpips_alex.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a58dec19353050ca82f0dec711b9ffa300e89fd4120036ffcc8ed899d00bed1 +size 9989260 diff --git a/samples/Tests/Games/FPStest.cs b/samples/Tests/Games/FPStest.cs deleted file mode 100644 index 84519c68cc..0000000000 --- a/samples/Tests/Games/FPStest.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class FPSTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\FirstPersonShooter"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("B12AF970-1F11-4BC8-9571-3B4DA9E20F05")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.KeyPress(Keys.Space, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Games/JumpyJetTest.cs b/samples/Tests/Games/JumpyJetTest.cs deleted file mode 100644 index 3f0bfdd39b..0000000000 --- a/samples/Tests/Games/JumpyJetTest.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class JumpyJetTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\JumpyJet"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("1C9E733A-16BB-48C3-A4DE-722B61EED994")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.KeyPress(Keys.Space, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Games/RPGTest.cs b/samples/Tests/Games/RPGTest.cs deleted file mode 100644 index c100425e06..0000000000 --- a/samples/Tests/Games/RPGTest.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class RPGTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\TopDownRPG"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("A363FBC5-89EF-4E7A-B870-6D070813D034")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.KeyPress(Keys.Space, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Games/SpaceEscapeTest.cs b/samples/Tests/Games/SpaceEscapeTest.cs deleted file mode 100644 index 95bd7b2046..0000000000 --- a/samples/Tests/Games/SpaceEscapeTest.cs +++ /dev/null @@ -1,49 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class SpaceEscapeTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\SpaceEscape"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("F9C4B79D-E313-47BC-9287-75A0395B8AC4")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.496875f, 0.8010563f), TimeSpan.FromMilliseconds(250)); - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.KeyPress(Keys.Space, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Games/TPPTest.cs b/samples/Tests/Games/TPPTest.cs deleted file mode 100644 index 4326061d65..0000000000 --- a/samples/Tests/Games/TPPTest.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class TPPTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\ThirdPersonPlatformer"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("990311E4-152B-458D-8CBD-180903845DA7")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.KeyPress(Keys.Space, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Generator/SampleGenerator.cs b/samples/Tests/Generator/SampleGenerator.cs new file mode 100644 index 0000000000..dbb5d808e0 --- /dev/null +++ b/samples/Tests/Generator/SampleGenerator.cs @@ -0,0 +1,83 @@ +// 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; +using System.IO; +using System.Linq; +using Stride.Assets.Presentation; +using Stride.Assets.Presentation.Templates; +using Stride.Assets.Templates; +using Stride.Core.Assets; +using Stride.Core.Assets.Templates; +using Stride.Core.Diagnostics; +using Stride.Core.IO; + +namespace Stride.Samples.Generator; + +/// +/// Regenerates a sample from its template GUID into . Returns the +/// resulting so callers can inspect or build the generated projects. +/// Caller is responsible for arranging the MSBuild environment via +/// PackageSessionPublicHelper.FindAndSetMSBuildVersion() before invoking this. +/// +public static class SampleGenerator +{ + public static PackageSession Generate(UDirectory outputPath, Guid templateGuid, string sampleName, LoggerResult logger) + { + if (!outputPath.IsAbsolute) + outputPath = UPath.Combine(Environment.CurrentDirectory, outputPath); + + Console.WriteLine($"Bootstrapping: {sampleName}"); + + var session = new PackageSession(); + var generator = TemplateSampleGenerator.Default; + + logger.MessageLogged += (sender, eventArgs) => Console.WriteLine(eventArgs.Message.Text); + + var parameters = new SessionTemplateGeneratorParameters { Session = session, Unattended = true }; + TemplateSampleGenerator.SetParameters( + parameters, + AssetRegistry.SupportedPlatforms + .Where(x => x.Type == Core.PlatformType.Windows) + .Select(x => new SelectedSolutionPlatform(x, x.Templates.FirstOrDefault())) + .ToList()); + + session.SolutionPath = UPath.Combine(outputPath, sampleName + ".sln"); + + if (Directory.Exists(outputPath)) + { + try + { + Directory.Delete(outputPath, recursive: true); + } + catch (Exception) + { + logger.Warning($"Unable to delete directory [{outputPath}]"); + } + } + + StrideDefaultAssetsPlugin.LoadDefaultTemplates(); + var strideTemplates = TemplateManager.FindTemplates(session); + + parameters.Description = strideTemplates.First(x => x.Id == templateGuid); + parameters.Name = sampleName; + parameters.Namespace = sampleName; + parameters.OutputDirectory = outputPath; + parameters.Logger = logger; + + if (!generator.PrepareForRun(parameters).Result) + logger.Error("PrepareForRun returned false for the TemplateSampleGenerator"); + + if (!generator.Run(parameters)) + logger.Error("Run returned false for the TemplateSampleGenerator"); + + // Run the platforms updater so the generated csproj/template structure matches the latest sdtpl pass. + var updaterTemplate = strideTemplates.First(x => x.FullPath.ToString().EndsWith("UpdatePlatforms.sdtpl", StringComparison.Ordinal)); + parameters.Description = updaterTemplate; + + if (logger.HasErrors) + throw new InvalidOperationException($"Error generating sample {sampleName} from template:\r\n{logger.ToText()}"); + + return session; + } +} diff --git a/samples/Tests/Graphics/AnimatedModelTest.cs b/samples/Tests/Graphics/AnimatedModelTest.cs deleted file mode 100644 index a4c3a5021a..0000000000 --- a/samples/Tests/Graphics/AnimatedModelTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class AnimatedModelTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\AnimatedModel"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("99371864-55BD-4C78-B25C-42471F977540")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - //Play Idle anim and take screenshot - game.Tap(new Vector2(0.83f, 0.05f), TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - - //Play Run anim and take screenshot - game.Tap(new Vector2(0.83f, 0.15f), TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Graphics/CustomEffectTest.cs b/samples/Tests/Graphics/CustomEffectTest.cs deleted file mode 100644 index 6e13a6bf54..0000000000 --- a/samples/Tests/Graphics/CustomEffectTest.cs +++ /dev/null @@ -1,42 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class CustomEffectTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\CustomEffect"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("16476A4C-C131-4F48-865A-288EC7D5445F")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Graphics/MaterialShaderTest.cs b/samples/Tests/Graphics/MaterialShaderTest.cs deleted file mode 100644 index 3c0d95b5d9..0000000000 --- a/samples/Tests/Graphics/MaterialShaderTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class MaterialShaderTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\MaterialShader"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("f80f8a38-c05a-44bd-ab6d-d2a4f1cf4c58")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - game.TakeScreenshot(); - - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - } -} diff --git a/samples/Tests/Graphics/SpriteFontsTest.cs b/samples/Tests/Graphics/SpriteFontsTest.cs deleted file mode 100644 index 5b805bebc8..0000000000 --- a/samples/Tests/Graphics/SpriteFontsTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class SpriteFontsTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\SpriteFonts"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("1EEB50EC-1AA7-4D1F-9DDD-E5E12404B001")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(4000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(4000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(5000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(4000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(4000)); - game.TakeScreenshot(); - } - } - } -} diff --git a/samples/Tests/Graphics/SpriteStudioDemoTest.cs b/samples/Tests/Graphics/SpriteStudioDemoTest.cs deleted file mode 100644 index acc9787d45..0000000000 --- a/samples/Tests/Graphics/SpriteStudioDemoTest.cs +++ /dev/null @@ -1,51 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class SpriteStudioDemoTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\SpriteStudioDemo"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("6BE30E8D-9346-4130-87BE-12BF9CC362DE")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - game.Tap(new Vector2(0.83f, 0.05f), TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - - game.KeyPress(Keys.Space, TimeSpan.FromMilliseconds(200)); - game.Wait(TimeSpan.FromMilliseconds(100)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Input/GravitySensorTest.cs b/samples/Tests/Input/GravitySensorTest.cs deleted file mode 100644 index 3a8a082d0b..0000000000 --- a/samples/Tests/Input/GravitySensorTest.cs +++ /dev/null @@ -1,56 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class GravitySensorTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\GravitySensor"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("7174D040-C0FB-4D5C-8170-3411AD8AA4C2")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - game.KeyPress(Keys.Down, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - - game.KeyPress(Keys.Up, TimeSpan.FromMilliseconds(1000)); - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - - game.KeyPress(Keys.Left, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Input/TouchInputsTest.cs b/samples/Tests/Input/TouchInputsTest.cs deleted file mode 100644 index e509722f38..0000000000 --- a/samples/Tests/Input/TouchInputsTest.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class TouchInputsTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\TouchInputs"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("662A15A4-92C1-4C43-BE06-0303C9E2FF50")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - game.Drag(new Vector2(0.7f, 0.7f), new Vector2(0.1f, 0.1f), TimeSpan.FromMilliseconds(200), - TimeSpan.FromMilliseconds(500)); - game.TakeScreenshot(); - - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/LocalConnectionRouterInitializer.cs b/samples/Tests/LocalConnectionRouterInitializer.cs deleted file mode 100644 index 3c5cdc6c35..0000000000 --- a/samples/Tests/LocalConnectionRouterInitializer.cs +++ /dev/null @@ -1,65 +0,0 @@ -// 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; -using System.Diagnostics; -using System.IO; -using System.Net.Sockets; -using System.Threading; -using Stride.Core; -using Stride.Engine.Network; - -namespace Stride.Samples.Tests -{ - //This is how we inject the assembly to run automatically at game start, paired with Stride.targets and the msbuild property StrideAutoTesting - internal class LocalConnectionRouterInitializer - { - [ModuleInitializer] - public static void Initialize() - { - // Locate connection router - var connectionRouterPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"Stride.ConnectionRouter.exe"); - if (!File.Exists(connectionRouterPath)) - throw new InvalidOperationException("Connection router not found"); - - // Kill any existing connection router - foreach (var process in Process.GetProcessesByName("Stride.ConnectionRouter")) - { - try - { - process.Kill(); - process.WaitForExit(); - break; - } - catch (Exception) - { - } - } - - // Start connection router - var connectionRouterProcess = Process.Start(connectionRouterPath); - // attach job so that it gets killed when tests are finished - new AttachedChildProcessJob(connectionRouterProcess); - - // Wait for port to open - using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) - { - // Try during 5 seconds (10 * 500 msec) - for (int i = 0; i < 10; ++i) - { - try - { - socket.Connect("localhost", RouterClient.DefaultPort); - } - catch (SocketException) - { - // Try again in 500 msec - Thread.Sleep(500); - continue; - } - break; - } - } - } - } -} diff --git a/samples/Tests/Particles/ParticlesSampleTest.cs b/samples/Tests/Particles/ParticlesSampleTest.cs deleted file mode 100644 index 9317198ca8..0000000000 --- a/samples/Tests/Particles/ParticlesSampleTest.cs +++ /dev/null @@ -1,69 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class ParticlesSampleTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\ParticlesSample"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("35C3FB4D-2A6E-40EB-825E-D4E5670FEE78")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(5000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Physics/PhysicsSampleTest.cs b/samples/Tests/Physics/PhysicsSampleTest.cs deleted file mode 100644 index fd93004105..0000000000 --- a/samples/Tests/Physics/PhysicsSampleTest.cs +++ /dev/null @@ -1,88 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class PhysicsSampleTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\PhysicsSample"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("d20d150b-d3cb-454e-8c11-620b4c9d393f")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - // X:0.8367187 Y:0.9375 - - // Constraints - game.TakeScreenshot(); - - game.Tap(new Vector2(0.83f, 0.93f), TimeSpan.FromMilliseconds(200)); - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.83f, 0.93f), TimeSpan.FromMilliseconds(200)); - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.83f, 0.93f), TimeSpan.FromMilliseconds(200)); - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.83f, 0.93f), TimeSpan.FromMilliseconds(200)); - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.83f, 0.93f), TimeSpan.FromMilliseconds(200)); - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - - // VolumeTrigger - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - - // Raycasting - game.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(200)); // Transition to the next scene - game.Wait(TimeSpan.FromMilliseconds(1000)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.3304687f, 0.6055555f), TimeSpan.FromMilliseconds(200)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.496875f, 0.4125f), TimeSpan.FromMilliseconds(200)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.659375f, 0.5319445f), TimeSpan.FromMilliseconds(200)); - game.TakeScreenshot(); - - game.KeyPress(Keys.Right, TimeSpan.FromMilliseconds(500)); - game.Wait(TimeSpan.FromMilliseconds(500)); - } - } - } -} diff --git a/samples/Tests/Runner/ScreenshotRunner.cs b/samples/Tests/Runner/ScreenshotRunner.cs new file mode 100644 index 0000000000..11172148fa --- /dev/null +++ b/samples/Tests/Runner/ScreenshotRunner.cs @@ -0,0 +1,373 @@ +// 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.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using Stride.Assets.Presentation; +using Stride.Core.Assets; +using Stride.Core.Assets.Templates; +using Stride.Core.Diagnostics; +using Stride.Core.IO; +using Stride.Samples.Generator; + +namespace Stride.SampleScreenshotRunner; + +/// +/// Library entry point for the embed-and-run screenshot regression pipeline. For each sample: +/// regenerate from template → dotnet build -p:StrideAutoTesting=true → launch the exe → +/// wait for done.json or timeout → copy the resulting screenshot-test/ artifact dir under +/// the chosen output root. Designed to be called in-process by xunit theory tests; subprocess +/// boundaries (build, sample exe) stay subprocesses for crash isolation. +/// +public static class ScreenshotRunner +{ + private const int LaunchTimeoutSeconds = 60; + + private static readonly object InitLock = new(); + private static bool initialized; + private static List? cachedFixtures; + private static string? cachedFixturesWorktree; + + /// + /// One-time setup: initialize MSBuild, load Stride templates. Safe to call multiple times. + /// + public static void Initialize() + { + lock (InitLock) + { + if (initialized) + return; + PackageSessionPublicHelper.FindAndSetMSBuildVersion(); + StrideDefaultAssetsPlugin.LoadDefaultTemplates(); + initialized = true; + } + } + + /// + /// Regenerate from its template, build it with + /// StrideAutoTesting=true -p:Configuration=<configuration>, launch the exe, wait for it + /// to write done.json or hit the 60s timeout, copy the captured screenshots + done.json + + /// error.log into <captureRoot>/<sampleName>/. Build and launch stdout/stderr land + /// in build.log and launch.log under that dir — callers (e.g. the xunit wrapper) can dump them + /// to ITestOutputHelper after this returns. + /// + public static SampleResult RunOne(string sampleName, string captureRoot, string worktreeRoot, bool headless = true, string configuration = "Debug") + { + Initialize(); + + var fixtures = LoadFixtures(worktreeRoot); + var fixture = fixtures.FirstOrDefault(f => string.Equals(f.SampleName, sampleName, StringComparison.OrdinalIgnoreCase)); + if (fixture is null) + { + return new SampleResult + { + Name = sampleName, + Status = "unknown-sample", + Detail = $"No fixture in tests/Stride.Samples.Tests/<{sampleName}>.cs. Available: {string.Join(", ", fixtures.Select(f => f.SampleName))}", + }; + } + + EnsureSamplesGeneratedTargets(Path.Combine(worktreeRoot, "samplesGenerated")); + var sampleDir = Path.Combine(worktreeRoot, "samplesGenerated", fixture.SampleName); + var regenResult = RegenerateSample(fixture, sampleDir); + if (regenResult is not null) + return regenResult; + + return RunSample(sampleDir, captureRoot, headless, configuration); + } + + /// Returns the catalog of discovered fixtures (cached per ). + public static IReadOnlyList LoadFixtures(string worktreeRoot) + { + Initialize(); + lock (InitLock) + { + if (cachedFixtures is not null && string.Equals(cachedFixturesWorktree, worktreeRoot, StringComparison.OrdinalIgnoreCase)) + return cachedFixtures; + var catalog = LoadTemplateCatalogByGuid(); + cachedFixtures = DiscoverFixtures(worktreeRoot, catalog); + cachedFixturesWorktree = worktreeRoot; + return cachedFixtures; + } + } + + /// + /// Walks (same source GameStudio's New Project + /// wizard reads) and returns it keyed by template GUID. + /// + private static Dictionary LoadTemplateCatalogByGuid() + { + var session = new PackageSession(); + return TemplateManager.FindTemplates(session) + .Where(t => t is TemplateSampleDescription) + .ToDictionary(t => t.Id, t => t.DefaultOutputName ?? t.Name); + } + + /// + /// Scans tests/Stride.Samples.Tests/*.cs for [ScreenshotTest(TemplateId = "...")] attribute + /// usages. Cross-references each GUID with the template catalog to confirm the template still + /// exists and to get its current name. Skips files that don't carry the attribute. + /// + private static List DiscoverFixtures(string worktreeRoot, Dictionary catalog) + { + var fixturesDir = Path.Combine(worktreeRoot, "tests", "Stride.Samples.Tests"); + if (!Directory.Exists(fixturesDir)) + return []; + + var idPattern = new Regex( + @"\[\s*ScreenshotTest\s*\(\s*TemplateId\s*=\s*""([^""]+)""\s*\)\s*\]", + RegexOptions.Compiled); + + var fixtures = new List(); + foreach (var file in Directory.EnumerateFiles(fixturesDir, "*.cs")) + { + var match = idPattern.Match(File.ReadAllText(file)); + if (!match.Success) + continue; + if (!Guid.TryParse(match.Groups[1].Value, out var guid)) + { + Console.Error.WriteLine($"[{Path.GetFileName(file)}] TemplateId is not a valid GUID; skipping."); + continue; + } + if (!catalog.TryGetValue(guid, out var templateName)) + { + Console.Error.WriteLine($"[{Path.GetFileName(file)}] TemplateId {guid} not found in template catalog; skipping."); + continue; + } + fixtures.Add(new DiscoveredFixture(file, guid, templateName)); + } + return fixtures; + } + + private static SampleResult RunSample(string sampleDir, string outputRoot, bool headless, string configuration) + { + sampleDir = Path.GetFullPath(sampleDir); + outputRoot = Path.GetFullPath(outputRoot); + var sampleName = Path.GetFileName(sampleDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var sampleOut = Path.Combine(outputRoot, sampleName); + if (Directory.Exists(sampleOut)) + Directory.Delete(sampleOut, recursive: true); + Directory.CreateDirectory(sampleOut); + + var stopwatch = Stopwatch.StartNew(); + var result = new SampleResult { Name = sampleName, Status = "unknown" }; + + var windowsCsproj = Path.Combine(sampleDir, $"{sampleName}.Windows", $"{sampleName}.Windows.csproj"); + if (!File.Exists(windowsCsproj)) + { + result.Status = "missing-csproj"; + result.Detail = windowsCsproj; + result.Duration = stopwatch.Elapsed; + return result; + } + + Console.WriteLine($"[{sampleName}] building (Configuration={configuration})..."); + var buildLog = Path.Combine(sampleOut, "build.log"); + var buildOk = RunProcess("dotnet", $"build \"{windowsCsproj}\" -p:StrideAutoTesting=true -p:Configuration={configuration}", buildLog, sampleDir, env: null, timeoutSeconds: 300); + if (!buildOk) + { + result.Status = "build-failed"; + result.Detail = buildLog; + result.Duration = stopwatch.Elapsed; + return result; + } + + // Output path varies: generated samples set RuntimeIdentifier=win-x64, hand-checked-in samples don't. + var exeCandidates = new[] + { + Path.Combine(sampleDir, "Bin", "Windows", configuration, "win-x64", $"{sampleName}.Windows.exe"), + Path.Combine(sampleDir, "Bin", "Windows", configuration, $"{sampleName}.Windows.exe"), + }; + var exePath = exeCandidates.FirstOrDefault(File.Exists); + if (exePath is null) + { + result.Status = "missing-exe"; + result.Detail = string.Join(" | ", exeCandidates); + result.Duration = stopwatch.Elapsed; + return result; + } + + var inProcessOutputDir = Path.Combine(Path.GetDirectoryName(exePath)!, "screenshot-test"); + if (Directory.Exists(inProcessOutputDir)) + Directory.Delete(inProcessOutputDir, recursive: true); + + Console.WriteLine($"[{sampleName}] launching..."); + var launchLog = Path.Combine(sampleOut, "launch.log"); + var env = new Dictionary(); + if (headless) + env["STRIDE_GRAPHICS_SOFTWARE_RENDERING"] = "1"; + var launchOk = RunProcess(exePath, "", launchLog, Path.GetDirectoryName(exePath)!, env, LaunchTimeoutSeconds); + + // Whether the process exited cleanly or not, copy whatever's in the in-process output dir. + if (Directory.Exists(inProcessOutputDir)) + { + CopyDirectory(inProcessOutputDir, sampleOut); + } + + result.Duration = stopwatch.Elapsed; + + var donePath = Path.Combine(sampleOut, "done.json"); + if (File.Exists(donePath)) + { + try + { + using var stream = File.OpenRead(donePath); + var doc = JsonDocument.Parse(stream); + if (doc.RootElement.TryGetProperty("status", out var statusEl)) + result.Status = statusEl.GetString() ?? "unknown"; + if (doc.RootElement.TryGetProperty("screenshots", out var shotsEl) && shotsEl.ValueKind == JsonValueKind.Array) + result.ScreenshotCount = shotsEl.GetArrayLength(); + } + catch (Exception ex) + { + result.Status = "done-json-parse-error"; + result.Detail = ex.Message; + } + } + else + { + result.Status = launchOk ? "no-done-json" : "crashed-no-done-json"; + } + + return result; + } + + private static bool RunProcess(string fileName, string arguments, string logPath, string workingDir, IDictionary? env, int timeoutSeconds) + { + var psi = new ProcessStartInfo(fileName, arguments) + { + WorkingDirectory = workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + if (env is not null) + { + foreach (var (k, v) in env) + psi.Environment[k] = v; + } + + using var process = Process.Start(psi)!; + using var log = new StreamWriter(File.Create(logPath)); + process.OutputDataReceived += (_, e) => { if (e.Data is not null) log.WriteLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data is not null) log.WriteLine("[stderr] " + e.Data); }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(TimeSpan.FromSeconds(timeoutSeconds))) + { + try { process.Kill(entireProcessTree: true); } catch { } + log.WriteLine($"[runner] killed after {timeoutSeconds}s timeout"); + return false; + } + return process.ExitCode == 0; + } + + /// + /// Regenerate one sample from its template into , then copy the + /// fixture file into <targetDir>/<SampleName>.Game/Tests/ScreenshotTest.cs. + /// Returns null on success; a failure SampleResult on regen error. + /// + private static SampleResult? RegenerateSample(DiscoveredFixture fixture, string targetDir) + { + Console.WriteLine($"[{fixture.SampleName}] regenerating from template {fixture.TemplateId}"); + var stopwatch = Stopwatch.StartNew(); + var logger = new LoggerResult(); + try + { + SampleGenerator.Generate(new UDirectory(targetDir), fixture.TemplateId, fixture.SampleName, logger); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[{fixture.SampleName}] regen failed: {ex.Message}"); + return new SampleResult + { + Name = fixture.SampleName, + Status = "regen-failed", + Detail = ex.Message + "\n" + logger.ToText(), + Duration = stopwatch.Elapsed, + }; + } + + var fixtureDest = Path.Combine(targetDir, $"{fixture.SampleName}.Game", "Tests", "ScreenshotTest.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(fixtureDest)!); + File.Copy(fixture.FixtureFile, fixtureDest, overwrite: true); + return null; + } + + /// + /// Mirror the canonical samples/Directory.Build.targets to samplesGenerated/Directory.Build.targets + /// so MSBuild walking up from each generated project finds the harness wiring. Lives above the + /// per-sample dir because SampleGenerator wipes the sample dir at the start of regeneration. + /// + private static void EnsureSamplesGeneratedTargets(string samplesGeneratedDir) + { + Directory.CreateDirectory(samplesGeneratedDir); + var targetsPath = Path.Combine(samplesGeneratedDir, "Directory.Build.targets"); + File.WriteAllText(targetsPath, HarnessTargetsContent); + } + + private const string HarnessTargetsContent = """ + + + + $(DefineConstants);STRIDE_AUTOTESTING + + + + + + + + + + + + + <_StrideAutoTestingBootstrapContent> +using System.Runtime.CompilerServices%3B +using Stride.Games.AutoTesting%3B + +internal static class _AutoTestingHarnessBootstrap +{ + [ModuleInitializer] + public static void ForceLoadHarness() => AutoTestingBootstrap.EnsureLoaded()%3B +} + + + + + +"""; + + private static void CopyDirectory(string source, string dest) + { + Directory.CreateDirectory(dest); + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(source, file); + var target = Path.Combine(dest, rel); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, overwrite: true); + } + } +} + +/// Fixture discovered in tests/Stride.Samples.Tests/<Name>.cs: GUID + resolved template name + source file. +public sealed record DiscoveredFixture(string FixtureFile, Guid TemplateId, string SampleName); + +/// Outcome of a single sample run. +public sealed class SampleResult +{ + public string Name { get; set; } = ""; + public string Status { get; set; } = ""; + public int ScreenshotCount { get; set; } + public TimeSpan Duration { get; set; } + public string? Detail { get; set; } +} diff --git a/samples/Tests/SampleScreenshotTests.cs b/samples/Tests/SampleScreenshotTests.cs new file mode 100644 index 0000000000..2432dc7340 --- /dev/null +++ b/samples/Tests/SampleScreenshotTests.cs @@ -0,0 +1,113 @@ +// 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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Stride.SampleScreenshotComparator; +using Stride.SampleScreenshotRunner; +using Xunit; +using Xunit.Abstractions; + +namespace Stride.Samples.Tests +{ + /// + /// xunit wrapper around the embed-and-run screenshot regression pipeline. One + /// theory entry per file in tests/Stride.Samples.Tests/*.cs, so a developer can run a single sample's + /// regression test from VS / Rider / `dotnet test --filter` without going through GH Actions. + /// CI uses the same path — the workflow just runs `dotnet test` and lets xunit drive every sample. + /// + /// Each entry calls (regen + build + launch sample as a + /// subprocess + collect screenshots) then (in-proc + /// LPIPS scoring). Process boundaries are kept where they matter — the sample game runs in its + /// own process for crash isolation — but the orchestrator and comparator are now in-proc calls. + /// + [CollectionDefinition("Screenshots", DisableParallelization = true)] + public class ScreenshotsCollection { } + + [Collection("Screenshots")] + public class SampleScreenshotTests + { + private readonly ITestOutputHelper output; + public SampleScreenshotTests(ITestOutputHelper output) => this.output = output; + + public static IEnumerable Samples() + { + var fixturesDir = Path.Combine(WorktreeRoot(), "tests", "Stride.Samples.Tests"); + if (!Directory.Exists(fixturesDir)) + yield break; + foreach (var file in Directory.EnumerateFiles(fixturesDir, "*.cs")) + yield return new object[] { Path.GetFileNameWithoutExtension(file) }; + } + + [Theory] + [MemberData(nameof(Samples))] + public void Sample(string name) + { + var worktree = WorktreeRoot(); + var captureRoot = Path.Combine(worktree, "screenshot-out"); + var sampleOut = Path.Combine(captureRoot, name); + if (Directory.Exists(sampleOut)) + Directory.Delete(sampleOut, recursive: true); + + // Capture: regenerate sample, build with StrideAutoTesting=true, launch the sample exe + // in its own process, collect the harness's screenshots + done.json into //. + var capture = ScreenshotRunner.RunOne(name, captureRoot, worktree); + output.WriteLine($"[capture] {capture.Status} screenshots={capture.ScreenshotCount} duration={capture.Duration.TotalSeconds:F1}s"); + if (capture.Detail is not null) output.WriteLine($"[capture] detail: {capture.Detail}"); + + // Relay subprocess output (sample build + sample exe stdout/stderr) into the test report + // so failure modes that happen inside those subprocesses are visible without artifact download. + DumpLog(Path.Combine(sampleOut, "build.log"), "build.log"); + DumpLog(Path.Combine(sampleOut, "launch.log"), "launch.log"); + DumpLog(Path.Combine(sampleOut, "error.log"), "error.log"); + + Assert.True(capture.Status == "ok", $"Capture status was '{capture.Status}' (expected 'ok'). Detail: {capture.Detail}"); + + // 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"); + var baselineDir = Path.Combine(worktree, "tests", "Stride.Samples.Tests"); + var results = ScreenshotComparator.Compare(captureRoot, baselineDir, sampleFilter: name, modelPath: modelPath); + + 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); + + // 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. + var sampleDir = Path.Combine(worktree, "samplesGenerated", name); + if (Directory.Exists(sampleDir)) + { + try { Directory.Delete(sampleDir, recursive: true); } + catch (Exception ex) { output.WriteLine($"[cleanup] failed to delete '{sampleDir}': {ex.Message}"); } + } + } + + private void DumpLog(string path, string label) + { + if (!File.Exists(path)) return; + output.WriteLine($"--- {label} ---"); + foreach (var line in File.ReadAllLines(path)) + output.WriteLine(line); + } + + /// Walk up from the cwd until we hit a NuGet.config — the worktree root. + private static string WorktreeRoot() + { + var dir = new DirectoryInfo(Environment.CurrentDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "NuGet.config")) || File.Exists(Path.Combine(dir.FullName, "nuget.config"))) + return dir.FullName; + dir = dir.Parent; + } + throw new InvalidOperationException("Could not locate worktree root from " + Environment.CurrentDirectory); + } + } +} diff --git a/samples/Tests/SampleTestFixture.cs b/samples/Tests/SampleTestFixture.cs deleted file mode 100644 index 743474d57a..0000000000 --- a/samples/Tests/SampleTestFixture.cs +++ /dev/null @@ -1,110 +0,0 @@ -// 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; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Stride.Assets.Presentation; -using Stride.Assets.Presentation.Templates; -using Stride.Assets.Templates; -using Stride.Core.Assets; -using Stride.Core.Assets.Templates; -using Stride.Core.Diagnostics; -using Stride.Core.IO; - -namespace Stride.Samples.Tests -{ - public class SampleTestFixture : IDisposable - { - public SampleTestFixture(UDirectory outputPath, Guid templateGuid) - { - // Setup MSBuild - PackageSessionPublicHelper.FindAndSetMSBuildVersion(); - - var logger = new LoggerResult(); - var sampleName = outputPath.GetDirectoryName(); - - var session = GenerateSample(outputPath, templateGuid, sampleName, logger); - CompileSample(logger, sampleName, session); - } - - private static void CompileSample(LoggerResult logger, string sampleName, PackageSession session) - { - var project = session.Projects.OfType().First(x => x.Platform == Core.PlatformType.Windows); - - var buildResult = VSProjectHelper.CompileProjectAssemblyAsync(project.FullPath, logger, extraProperties: new Dictionary { { "StrideAutoTesting", "true" } }).BuildTask.Result; - if (logger.HasErrors) - { - throw new InvalidOperationException($"Error compiling sample {sampleName}:\r\n{logger.ToText()}"); - } - } - - private static PackageSession GenerateSample(UDirectory outputPath, Guid templateGuid, string sampleName, LoggerResult logger) - { - // Make output path absolute - if (!outputPath.IsAbsolute) - outputPath = UPath.Combine(Environment.CurrentDirectory, outputPath); - - Console.WriteLine(@"Bootstrapping: " + sampleName); - - var session = new PackageSession(); - var generator = TemplateSampleGenerator.Default; - - // Ensure progress is shown while it is happening. - logger.MessageLogged += (sender, eventArgs) => Console.WriteLine(eventArgs.Message.Text); - - var parameters = new SessionTemplateGeneratorParameters { Session = session }; - parameters.Unattended = true; - TemplateSampleGenerator.SetParameters( - parameters, - AssetRegistry.SupportedPlatforms.Where(x => x.Type == Core.PlatformType.Windows).Select(x => new SelectedSolutionPlatform(x, x.Templates.FirstOrDefault())).ToList(), - addGamesTesting: true); - - session.SolutionPath = UPath.Combine(outputPath, sampleName + ".sln"); - - // Properly delete previous version - if (Directory.Exists(outputPath)) - { - try - { - Directory.Delete(outputPath, true); - } - catch (Exception) - { - logger.Warning($"Unable to delete directory [{outputPath}]"); - } - } - - // Load templates - StrideDefaultAssetsPlugin.LoadDefaultTemplates(); - var strideTemplates = TemplateManager.FindTemplates(session); - - parameters.Description = strideTemplates.First(x => x.Id == templateGuid); - parameters.Name = sampleName; - parameters.Namespace = sampleName; - parameters.OutputDirectory = outputPath; - parameters.Logger = logger; - - if (!generator.PrepareForRun(parameters).Result) - logger.Error("PrepareForRun returned false for the TemplateSampleGenerator"); - - if (!generator.Run(parameters)) - logger.Error("Run returned false for the TemplateSampleGenerator"); - - var updaterTemplate = strideTemplates.First(x => x.FullPath.ToString().EndsWith("UpdatePlatforms.sdtpl", StringComparison.Ordinal)); - parameters.Description = updaterTemplate; - - if (logger.HasErrors) - { - throw new InvalidOperationException($"Error generating sample {sampleName} from template:\r\n{logger.ToText()}"); - } - - return session; - } - - public void Dispose() - { - - } - } -} diff --git a/samples/Tests/SampleTestsData.cs b/samples/Tests/SampleTestsData.cs deleted file mode 100644 index 9628ed3e41..0000000000 --- a/samples/Tests/SampleTestsData.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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.IO; -using Stride.Core; -using Stride.Core.Assets; -using Xunit; - -// We run test one by one (various things are not thread-safe) -[assembly: CollectionBehavior(DisableTestParallelization = true)] - -namespace Stride.Samples.Tests -{ - class SampleTestsData - { -#if TEST_ANDROID - public const PlatformType TestPlatform = PlatformType.Android; -#elif TEST_IOS - public const PlatformType TestPlatform = PlatformType.iOS; -#else - public const PlatformType TestPlatform = PlatformType.Windows; -#endif - } -} diff --git a/samples/Tests/Stride.Samples.Tests.csproj b/samples/Tests/Stride.Samples.Tests.csproj index cc5a69c009..01b2e39c30 100644 --- a/samples/Tests/Stride.Samples.Tests.csproj +++ b/samples/Tests/Stride.Samples.Tests.csproj @@ -19,8 +19,11 @@ $(MSBuildThisFileDirectory)..\..\bin\Tests\$(MSBuildProjectName)\$(StridePlatform)\ - - + + + + @@ -32,9 +35,13 @@ - - - + + + + + + + diff --git a/samples/Tests/UI/GameMenuTest.cs b/samples/Tests/UI/GameMenuTest.cs deleted file mode 100644 index 5444011b33..0000000000 --- a/samples/Tests/UI/GameMenuTest.cs +++ /dev/null @@ -1,65 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class GameMenuTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\GameMenu"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("7ac2c705-6240-4ddc-af63-fc438d10f4de")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - game.TakeScreenshot(); - - /* - [GameMenu.MainScript]: Info: X:0.4765625 Y:0.8389084 - [GameMenu.MainScript]: Info: X:0.6609375 Y:0.7315141 - [GameMenu.MainScript]: Info: X:0.6609375 Y:0.7315141 - [GameMenu.MainScript]: Info: X:0.5390625 Y:0.7764084 - [GameMenu.MainScript]: Info: X:0.5390625 Y:0.7764084 - */ - - game.Tap(new Vector2(0.4765625f, 0.8389084f), TimeSpan.FromMilliseconds(250)); - game.Wait(TimeSpan.FromMilliseconds(250)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.6609375f, 0.7315141f), TimeSpan.FromMilliseconds(250)); - game.Wait(TimeSpan.FromMilliseconds(250)); - game.TakeScreenshot(); - - game.Tap(new Vector2(0.5390625f, 0.7764084f), TimeSpan.FromMilliseconds(250)); - game.Wait(TimeSpan.FromMilliseconds(250)); - game.TakeScreenshot(); - - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - } -} diff --git a/samples/Tests/UI/UIParticlesTest.cs b/samples/Tests/UI/UIParticlesTest.cs deleted file mode 100644 index b62e5daee1..0000000000 --- a/samples/Tests/UI/UIParticlesTest.cs +++ /dev/null @@ -1,59 +0,0 @@ -// 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; -using Xunit; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; -using Stride.Games.Testing; - -namespace Stride.Samples.Tests -{ - public class UIParticlesTest : IClassFixture - { - private const string Path = "..\\..\\..\\..\\..\\samplesGenerated\\UIParticles"; - - public class Fixture : SampleTestFixture - { - public Fixture() : base(Path, new Guid("DA4B1982-2A93-48FB-8EDA-7B13AD79E6A2")) - { - } - } - - [Fact] - public void TestLaunch() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - - [Fact] - public void TestInputs() - { - using (var game = new GameTestingClient(Path, SampleTestsData.TestPlatform)) - { - game.Wait(TimeSpan.FromMilliseconds(2000)); - - game.TakeScreenshot(); - - // TODO Simulate taps - - game.Tap(new Vector2(179f / 600f, 235f / 600f), TimeSpan.FromMilliseconds(150)); - game.Wait(TimeSpan.FromMilliseconds(250)); - game.TakeScreenshot(); - - game.Tap(new Vector2(360f / 600f, 328f / 600f), TimeSpan.FromMilliseconds(150)); - game.Wait(TimeSpan.FromMilliseconds(1250)); - game.TakeScreenshot(); - - game.Tap(new Vector2(179f / 600f, 235f / 600f), TimeSpan.FromMilliseconds(150)); - game.Wait(TimeSpan.FromMilliseconds(250)); - game.TakeScreenshot(); - - game.Wait(TimeSpan.FromMilliseconds(2000)); - } - } - } -} diff --git a/samples/Tests/app.config b/samples/Tests/app.config deleted file mode 100644 index 010837f4e5..0000000000 --- a/samples/Tests/app.config +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index 5e717b5e60..595a80c42d 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -11,7 +11,9 @@ + + @@ -43,25 +45,28 @@ - - - + + + + - + + - + - - - - - + + + + + + diff --git a/sources/assets/Stride.Core.Packages/NugetStore.cs b/sources/assets/Stride.Core.Packages/NugetStore.cs index 480096d404..3b0aa796fa 100644 --- a/sources/assets/Stride.Core.Packages/NugetStore.cs +++ b/sources/assets/Stride.Core.Packages/NugetStore.cs @@ -12,6 +12,7 @@ using NuGet.PackageManagement; using NuGet.Packaging; using NuGet.Packaging.Core; +using NuGet.Packaging.Signing; using NuGet.ProjectManagement; using NuGet.ProjectModel; using NuGet.Protocol; @@ -251,6 +252,8 @@ public IList GetPackagesInstalled(IEnumerable package /// A list of packages. public IEnumerable GetLocalPackages(string packageId) { + SyncLocalFolderSources(packageId); + var res = new List(); // We also scan rootDirectory for 1.x/2.x @@ -271,6 +274,76 @@ public IEnumerable GetLocalPackages(string packageId) return res; } + /// + /// Mirrors any .nupkg of from configured local-folder NuGet sources + /// (e.g. a worktree's bin/packages) into the global packages folder when the source nupkg + /// is newer than the extracted form. Lets dev-built packages flow into + /// without a separate restore step. + /// + private void SyncLocalFolderSources(string packageId) + { + if (InstallPath == null) + return; + + var resolver = new VersionFolderPathResolver(InstallPath); + + foreach (var source in PackageSources) + { + if (!source.IsLocal) + continue; + + IList localPkgs; + try + { + var v2 = new FindLocalPackagesResourceV2(source.Source); + localPkgs = v2.FindPackagesById(packageId, NativeLogger, CancellationToken.None).ToList(); + } + catch + { + continue; + } + + foreach (var pkg in localPkgs) + { + var nupkgPath = pkg.Path; + if (string.IsNullOrEmpty(nupkgPath) || !File.Exists(nupkgPath)) + continue; + + var hashPath = resolver.GetHashPath(pkg.Identity.Id, pkg.Identity.Version); + var nupkgWriteTime = File.GetLastWriteTimeUtc(nupkgPath); + + if (File.Exists(hashPath) && File.GetLastWriteTimeUtc(hashPath) >= nupkgWriteTime) + continue; + + var extractedDir = resolver.GetInstallPath(pkg.Identity.Id, pkg.Identity.Version); + if (Directory.Exists(extractedDir)) + { + try { Directory.Delete(extractedDir, recursive: true); } + catch { continue; } + } + + try + { + using var stream = File.OpenRead(nupkgPath); + GlobalPackagesFolderUtility.AddPackageAsync( + source: source.Source, + packageIdentity: pkg.Identity, + packageStream: stream, + globalPackagesFolder: InstallPath, + parentId: Guid.Empty, + clientPolicyContext: ClientPolicyContext.GetClientPolicy(settings, NativeLogger), + logger: NativeLogger, + token: CancellationToken.None).GetAwaiter().GetResult(); + } + catch + { + // Best-effort: if extraction fails (race with a concurrent build, locked file, etc.) + // fall through to whatever was already in the global cache. + } + } + } + } + /// /// Name of variable used to hold the version of . /// diff --git a/sources/editor/Stride.Assets.Presentation/Templates/TemplateSampleGenerator.cs b/sources/editor/Stride.Assets.Presentation/Templates/TemplateSampleGenerator.cs index 06611988d8..458fd6ff2a 100644 --- a/sources/editor/Stride.Assets.Presentation/Templates/TemplateSampleGenerator.cs +++ b/sources/editor/Stride.Assets.Presentation/Templates/TemplateSampleGenerator.cs @@ -26,17 +26,15 @@ public class TemplateSampleGenerator : SessionTemplateGenerator { private static readonly PropertyKey GeneratedPackageKey = new PropertyKey("GeneratedPackage", typeof(TemplateSampleGenerator)); private static readonly PropertyKey> PlatformsKey = new PropertyKey>("Platforms", typeof(TemplateSampleGenerator)); - private static readonly PropertyKey AddGamesTestingKey = new PropertyKey("AddGamesTesting", typeof(TemplateSampleGenerator)); public static readonly TemplateSampleGenerator Default = new TemplateSampleGenerator(); /// /// Sets the parameters required by this template when running in mode. /// - public static void SetParameters(SessionTemplateGeneratorParameters parameters, IEnumerable platforms, bool addGamesTesting = false) + public static void SetParameters(SessionTemplateGeneratorParameters parameters, IEnumerable platforms) { parameters.SetTag(PlatformsKey, new List(platforms)); - parameters.SetTag(AddGamesTestingKey, addGamesTesting); } public override bool IsSupportingTemplate(TemplateDescription templateDescription) @@ -166,17 +164,6 @@ protected override bool Generate(SessionTemplateGeneratorParameters parameters) var outputProject = (SolutionProject)Package.LoadProject(log, projectOutputFile); var msbuildProject = VSProjectHelper.LoadProject(outputProject.FullPath, platform: "NoPlatform"); - // If requested, add reference to Stride.Games.Testing - if (parameters.TryGetTag(AddGamesTestingKey)) - { - var items = msbuildProject.AddItem("PackageReference", "Stride.Games.Testing", new[] { new KeyValuePair("Version", StrideVersion.NuGetVersion), new KeyValuePair("PrivateAssets", "contentfiles;analyzers") }); - foreach (var item in items) - { - foreach (var metadata in item.Metadata) - metadata.Xml.ExpressedAsAttribute = true; - } - } - // Copy dependency files locally // We only want to copy the asset files. The raw files are in Resources and the game assets are in Assets. // If we copy each file locally they will be included in the package and we can then delete the dependency packages. diff --git a/sources/engine/Stride.Engine/Engine/Game.cs b/sources/engine/Stride.Engine/Engine/Game.cs index abe0dfca68..456221c538 100644 --- a/sources/engine/Stride.Engine/Engine/Game.cs +++ b/sources/engine/Stride.Engine/Engine/Game.cs @@ -310,7 +310,7 @@ public override void ConfirmRenderingSettings(bool gameCreation) var deviceManager = (GraphicsDeviceManager)graphicsDeviceManager; - if (gameCreation) + if (gameCreation && !deviceManager.SkipBackBufferClampToWindow) { //if our device width or height is actually smaller then requested we use the device one deviceManager.PreferredBackBufferWidth = Context.RequestedWidth = Math.Min(deviceManager.PreferredBackBufferWidth, Window.ClientBounds.Width); @@ -318,7 +318,7 @@ public override void ConfirmRenderingSettings(bool gameCreation) } //these might get triggered even during game runtime, resize, orientation change - if (renderingSettings != null && renderingSettings.AdaptBackBufferToScreen) + if (!deviceManager.SkipBackBufferClampToWindow && renderingSettings != null && renderingSettings.AdaptBackBufferToScreen) { var deviceAr = Window.ClientBounds.Width / (float)Window.ClientBounds.Height; diff --git a/sources/engine/Stride.Games.AutoTesting/IScreenshotTest.cs b/sources/engine/Stride.Games.AutoTesting/IScreenshotTest.cs new file mode 100644 index 0000000000..56d0da3326 --- /dev/null +++ b/sources/engine/Stride.Games.AutoTesting/IScreenshotTest.cs @@ -0,0 +1,15 @@ +// 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.Threading.Tasks; + +namespace Stride.Games.AutoTesting; + +/// +/// Contract for a screenshot-regression session script. Implementations describe a sequence +/// of frame waits, simulated input, and screenshot captures via . +/// +public interface IScreenshotTest +{ + Task Run(IScreenshotTestContext ctx); +} diff --git a/sources/engine/Stride.Games.AutoTesting/IScreenshotTestContext.cs b/sources/engine/Stride.Games.AutoTesting/IScreenshotTestContext.cs new file mode 100644 index 0000000000..e4656c3cf4 --- /dev/null +++ b/sources/engine/Stride.Games.AutoTesting/IScreenshotTestContext.cs @@ -0,0 +1,58 @@ +// 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; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Engine; +using Stride.Input; + +namespace Stride.Games.AutoTesting; + +/// +/// Driver surface passed to an . All async members yield to the +/// game loop so the script reads top-to-bottom while the game keeps ticking. +/// +public interface IScreenshotTestContext +{ + /// Game instance running the sample. + Game Game { get; } + + /// Yield until Update ticks have elapsed. + Task WaitFrames(int frames); + + /// Yield until at least of game time has elapsed. + Task WaitTime(TimeSpan duration); + + /// + /// Capture the back buffer and write it as screenshots/<name>.png. Awaits the actual + /// capture. is the LPIPS distance above which the comparator + /// flags this frame as a regression — bump it for screenshots that contain unavoidable + /// nondeterminism (random particle emission, physics-driven trajectories, etc.). The default + /// (0.05) corresponds to "perceptually indistinguishable" for content that runs deterministically + /// under Game.IsFixedTimeStep. + /// + /// controls the Claude vision second-opinion that runs when + /// LPIPS is over threshold. true (default) = use the generic same-scene prompt. A + /// string = generic prompt + this extra guidance (e.g. "chick count must match"). + /// false/null = no fallback. The fallback only runs in the comparator and only + /// when LPIPS already failed, so it costs nothing on passing frames. + /// + /// + Task Screenshot(string name, float threshold = 0.05f, object? claudeFallback = null); + + /// Press on the simulated keyboard. Stays down until . + void PressKey(Keys key); + + /// Release on the simulated keyboard. + void ReleaseKey(Keys key); + + /// Press , hold for , release. + Task PressKey(Keys key, TimeSpan duration); + + /// Tap (0..1 in each axis) for . + Task Tap(Vector2 normalizedPosition, TimeSpan duration); + + /// Request the script ends successfully; the harness writes done.json with status="ok" and exits the game. + void Exit(int exitCode = 0); +} diff --git a/sources/engine/Stride.Games.AutoTesting/ScreenshotTestAttribute.cs b/sources/engine/Stride.Games.AutoTesting/ScreenshotTestAttribute.cs new file mode 100644 index 0000000000..f5e9b6f5ed --- /dev/null +++ b/sources/engine/Stride.Games.AutoTesting/ScreenshotTestAttribute.cs @@ -0,0 +1,23 @@ +// 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; + +namespace Stride.Games.AutoTesting; + +/// +/// Marks a class as the screenshot-test driver for a sample. The class must implement +/// and have a public parameterless constructor. Exactly one +/// such class may exist in the entry assembly when StrideAutoTesting=true. +/// +/// +/// The optional identifies which Stride sample template this fixture +/// targets — orchestrators read it to look the template up in TemplateManager.FindTemplates +/// without depending on filename conventions. Stays string-typed because attribute arguments must +/// be compile-time constants and isn't. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class ScreenshotTestAttribute : Attribute +{ + public string? TemplateId { get; init; } +} diff --git a/sources/engine/Stride.Games.AutoTesting/ScreenshotTestRunner.cs b/sources/engine/Stride.Games.AutoTesting/ScreenshotTestRunner.cs new file mode 100644 index 0000000000..4c137529d0 --- /dev/null +++ b/sources/engine/Stride.Games.AutoTesting/ScreenshotTestRunner.cs @@ -0,0 +1,364 @@ +// 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; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Engine; +using Stride.Games; +using Stride.Graphics; +using Stride.Input; + +namespace Stride.Games.AutoTesting; + +/// +/// Drives a attached to the entry assembly. Hooks +/// , wires simulated input, schedules the script as a Stride +/// micro-thread, captures back-buffer PNGs, and writes a done.json completion record +/// before exiting the game. +/// +public static class AutoTestingBootstrap +{ + /// + /// No-op invoked from a consumer's [ModuleInitializer] to force-load this assembly so the + /// 's own [ModuleInitializer] runs. The .NET runtime + /// defers loading until a method from the assembly is actually invoked, so referencing a + /// type with typeof() isn't enough on its own. + /// + public static void EnsureLoaded() { } +} + +internal sealed class ScreenshotTestRunner +{ + private const string OutputDirName = "screenshot-test"; + private const string ScreenshotsSubDir = "screenshots"; + private const string DoneFileName = "done.json"; + private const string ErrorLogName = "error.log"; + + private readonly Game game; + private readonly IScreenshotTest test; + private readonly string outputDir; + private readonly string screenshotsDir; + private readonly List captured = []; + private readonly ConcurrentQueue<(string Name, float Threshold, object? ClaudeFallback, TaskCompletionSource Tcs)> pendingScreenshots = new(); + private InputSourceSimulated simulatedInput = null!; + private KeyboardSimulated keyboard = null!; + private MouseSimulated mouse = null!; + private bool exitRequested; + private int exitCode; + + [ModuleInitializer] + internal static void RegisterAutoTestHook() + { + // Default to software rendering for deterministic captures; STRIDE_TESTS_GPU=1 opts back into the GPU. + if (Environment.GetEnvironmentVariable("STRIDE_TESTS_GPU") != "1") + Environment.SetEnvironmentVariable("STRIDE_GRAPHICS_SOFTWARE_RENDERING", "1"); + Game.GameStarted += OnGameStarted; + } + + + private static void OnGameStarted(object? sender, EventArgs e) + { + if (sender is not Game game) + return; + + // Skip back-buffer clamp so portrait samples don't get cropped on smaller host desktops. + // OnGameStarted fires before graphicsDeviceManager.CreateDevice, so the flag is in effect + // by the time the swap chain is created. + ((GraphicsDeviceManager)game.GraphicsDeviceManager).SkipBackBufferClampToWindow = true; + + // [ScreenshotTest] typically lives in the .Game assembly (library), not the .Windows entry + // assembly (exe). Scan every currently-loaded assembly that references Stride.Games.AutoTesting. + var harness = typeof(ScreenshotTestAttribute).Assembly.GetName().Name; + var testTypes = new List(); + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.IsDynamic) + continue; + // Cheap filter: assembly must reference our harness (or be it). + if (asm.GetName().Name != harness && !asm.GetReferencedAssemblies().Any(r => r.Name == harness)) + continue; + try + { + foreach (var t in asm.GetTypes()) + { + if (t.GetCustomAttribute() is not null) + testTypes.Add(t); + } + } + catch (ReflectionTypeLoadException ex) + { + foreach (var t in ex.Types.OfType()) + { + if (t.GetCustomAttribute() is not null) + testTypes.Add(t); + } + } + } + + if (testTypes.Count == 0) + return; + + if (testTypes.Count > 1) + throw new InvalidOperationException( + $"Stride.Games.AutoTesting: found {testTypes.Count} [ScreenshotTest] classes; exactly one is allowed. " + + $"Found: {string.Join(", ", testTypes.Select(t => t.FullName))}"); + + var testType = testTypes[0]; + if (!typeof(IScreenshotTest).IsAssignableFrom(testType)) + throw new InvalidOperationException($"[ScreenshotTest] class {testType.FullName} must implement {nameof(IScreenshotTest)}."); + + var test = (IScreenshotTest)Activator.CreateInstance(testType)!; + var runner = new ScreenshotTestRunner(game, test); + runner.Start(); + } + + private ScreenshotTestRunner(Game game, IScreenshotTest test) + { + this.game = game; + this.test = test; + + var exeDir = AppContext.BaseDirectory; + outputDir = Path.Combine(exeDir, OutputDirName); + screenshotsDir = Path.Combine(outputDir, ScreenshotsSubDir); + } + + private void Start() + { + try + { + Directory.CreateDirectory(screenshotsDir); + } + catch (Exception ex) + { + // Output dir not writable -> stderr only; we still try to run so the orchestrator sees a process. + Console.Error.WriteLine($"Stride.Games.AutoTesting: cannot create output dir '{outputDir}': {ex}"); + } + + // Mirror error stream into error.log alongside the test artifacts. + try + { + var errorLogPath = Path.Combine(outputDir, ErrorLogName); + var errorLog = new StreamWriter(File.Create(errorLogPath)) { AutoFlush = true }; + Console.SetError(new TeeWriter(Console.Error, errorLog)); + } + catch + { + // Best-effort logging. + } + + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + + // Wire simulated input so the script can press keys / tap regardless of platform window state. + simulatedInput = new InputSourceSimulated(); + game.Input.Sources.Clear(); + game.Input.Sources.Add(simulatedInput); + keyboard = simulatedInput.AddKeyboard(); + mouse = simulatedInput.AddMouse(); + + // CaptureSystem runs at the end of every Draw and processes pendingScreenshots queue. + game.GameSystems.Add(new CaptureSystem(this)); + + var ctx = new Context(this); + game.Script.AddTask(async () => + { + string 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); + Environment.ExitCode = exitCode; + if (!exitRequested) + { + exitRequested = true; + game.Exit(); + } + } + }); + } + + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + WriteDoneJson("crashed", e.ExceptionObject is Exception ex ? SerializeException(ex) : new { message = e.ExceptionObject?.ToString() }); + Console.Error.WriteLine(e.ExceptionObject); + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + WriteDoneJson("crashed", SerializeException(e.Exception)); + Console.Error.WriteLine(e.Exception); + e.SetObserved(); + } + + private void WriteDoneJson(string status, object? exceptionInfo) + { + try + { + var donePath = Path.Combine(outputDir, DoneFileName); + var payload = new + { + status, + screenshots = captured, + exception = exceptionInfo, + }; + File.WriteAllText(donePath, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Stride.Games.AutoTesting: failed to write {DoneFileName}: {ex}"); + } + } + + // ClaudeFallback is null (no fallback), true (generic prompt), or a string (extra guidance). + private sealed record CapturedScreenshot(string Name, float Threshold, object? ClaudeFallback); + + private static object SerializeException(Exception ex) => new + { + type = ex.GetType().FullName, + message = ex.Message, + stack = ex.ToString(), + }; + + // DXGI ignores backbuffer alpha; PNG viewers don't. Force-opaque so the saved frame + // matches what the user sees (FreeImage then strips the uniform alpha to 3-channel RGB). + private static unsafe void ForceAlphaOpaque(Image image) + { + var format = image.Description.Format; + if (format != PixelFormat.R8G8B8A8_UNorm && format != PixelFormat.R8G8B8A8_UNorm_SRgb && + format != PixelFormat.B8G8R8A8_UNorm && format != PixelFormat.B8G8R8A8_UNorm_SRgb) + return; + + var buffer = image.PixelBuffer[0]; + var ptr = (byte*)buffer.DataPointer; + int len = buffer.BufferStride; + for (int i = 3; i < len; i += 4) + ptr[i] = 0xFF; + } + + /// Game system that drains pending screenshot requests at the end of every Draw. + private sealed class CaptureSystem(ScreenshotTestRunner runner) : GameSystemBase(runner.game.Services) + { + public CaptureSystem InitOrder() + { + // Run after default GameSystems so the back buffer reflects the final composited frame. + DrawOrder = int.MaxValue; + Visible = true; + return this; + } + + public override void Initialize() + { + base.Initialize(); + DrawOrder = int.MaxValue; + Visible = true; + } + + public override void Draw(GameTime gameTime) + { + base.Draw(gameTime); + while (runner.pendingScreenshots.TryDequeue(out var pending)) + { + try + { + var path = Path.Combine(runner.screenshotsDir, pending.Name + ".png"); + var presenter = runner.game.GraphicsDevice.Presenter; + var commandList = runner.game.GraphicsContext.CommandList; + using var image = presenter.BackBuffer.GetDataAsImage(commandList); + ForceAlphaOpaque(image); + using var stream = File.Create(path); + image.Save(stream, ImageFileType.Png); + runner.captured.Add(new CapturedScreenshot(pending.Name, pending.Threshold, pending.ClaudeFallback)); + pending.Tcs.SetResult(); + } + catch (Exception ex) + { + pending.Tcs.SetException(ex); + } + } + } + } + + /// StreamWriter that mirrors writes to two underlying writers. + private sealed class TeeWriter(System.IO.TextWriter primary, System.IO.TextWriter secondary) : System.IO.TextWriter + { + public override Encoding Encoding => primary.Encoding; + + public override void Write(char value) { primary.Write(value); secondary.Write(value); } + public override void Write(string? value) { primary.Write(value); secondary.Write(value); } + public override void WriteLine(string? value) { primary.WriteLine(value); secondary.WriteLine(value); } + public override void Flush() { primary.Flush(); secondary.Flush(); } + } + + /// Implementation of handed to the user's script. + private sealed class Context(ScreenshotTestRunner runner) : IScreenshotTestContext + { + public Game Game => runner.game; + + public async Task WaitFrames(int frames) + { + for (int i = 0; i < frames; i++) + await runner.game.Script.NextFrame(); + } + + public async Task WaitTime(TimeSpan duration) + { + var deadline = runner.game.UpdateTime.Total + duration; + while (runner.game.UpdateTime.Total < deadline) + await runner.game.Script.NextFrame(); + } + + public Task Screenshot(string name, float threshold = 0.05f, object? claudeFallback = null) + { + // Default null → true (generic prompt). Pass `false` to opt out. + var tcs = new TaskCompletionSource(); + runner.pendingScreenshots.Enqueue((name, threshold, claudeFallback ?? (object)true, tcs)); + return tcs.Task; + } + + public void PressKey(Keys key) => runner.keyboard.SimulateDown(key); + + public void ReleaseKey(Keys key) => runner.keyboard.SimulateUp(key); + + public async Task PressKey(Keys key, TimeSpan duration) + { + runner.keyboard.SimulateDown(key); + await WaitTime(duration); + runner.keyboard.SimulateUp(key); + } + + public async Task Tap(Vector2 normalizedPosition, TimeSpan duration) + { + runner.mouse.SimulatePointer(PointerEventType.Pressed, normalizedPosition); + await WaitTime(duration); + runner.mouse.SimulatePointer(PointerEventType.Released, normalizedPosition); + } + + public void Exit(int exitCode) + { + runner.exitCode = exitCode; + Environment.ExitCode = exitCode; + runner.exitRequested = true; + runner.game.Exit(); + } + } +} diff --git a/sources/engine/Stride.Games.Testing/Stride.Games.Testing.csproj b/sources/engine/Stride.Games.AutoTesting/Stride.Games.AutoTesting.csproj similarity index 73% rename from sources/engine/Stride.Games.Testing/Stride.Games.Testing.csproj rename to sources/engine/Stride.Games.AutoTesting/Stride.Games.AutoTesting.csproj index bc5d5f8cb1..a217dfbcf8 100644 --- a/sources/engine/Stride.Games.Testing/Stride.Games.Testing.csproj +++ b/sources/engine/Stride.Games.AutoTesting/Stride.Games.AutoTesting.csproj @@ -3,16 +3,14 @@ true true + true true - --parameter-key --auto-module-initializer --serialization + --auto-module-initializer Properties\SharedAssemblyInfo.cs - - TestResultImage.cs - diff --git a/sources/engine/Stride.Games.Testing/GameTestingClient.cs b/sources/engine/Stride.Games.Testing/GameTestingClient.cs deleted file mode 100644 index 9178689d07..0000000000 --- a/sources/engine/Stride.Games.Testing/GameTestingClient.cs +++ /dev/null @@ -1,202 +0,0 @@ -// 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. - -#if STRIDE_PLATFORM_DESKTOP -using System; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Stride.Core; -using Stride.Core.Extensions; -using Stride.Core.Mathematics; -using Stride.Engine.Network; -using Stride.Games.Testing.Requests; -using Stride.Input; - -namespace Stride.Games.Testing -{ - /// - /// This class is to be consumed by Unit tests, see samples/Tests/Tests.sln - /// It will send requests to the router which in turn will route them to the running game - /// - public class GameTestingClient : IDisposable - { - private readonly SocketMessageLayer socketMessageLayer; - private readonly string gamePath; - private readonly string gameName; - private readonly string platformName; - private int screenShots; - - private readonly AutoResetEvent screenshotEvent = new AutoResetEvent(false); - - public GameTestingClient(string gamePath, PlatformType platform) - { - GameTestingSystem.Initialized = true; //prevent time-outs from test side!! - - this.gamePath = gamePath ?? throw new ArgumentNullException(nameof(gamePath)); - - gameName = Path.GetFileName(gamePath); - switch (platform) - { - case PlatformType.Windows: - platformName = "Windows"; - break; - case PlatformType.Android: - platformName = "Android"; - break; - case PlatformType.iOS: - platformName = "iOS"; - break; - case PlatformType.UWP: - platformName = "UWP"; - break; - default: - platformName = ""; - break; - } - - var url = $"/service/Stride.SamplesTestServer/{StrideVersion.NuGetVersion}/Stride.SamplesTestServer.exe"; - - var socketContext = RouterClient.RequestServer(url).Result; - - var success = false; - var message = ""; - var ev = new AutoResetEvent(false); - - socketMessageLayer = new SocketMessageLayer(socketContext, false); - - socketMessageLayer.AddPacketHandler(request => - { - success = !request.Error; - message = request.Message; - ev.Set(); - }); - - socketMessageLayer.AddPacketHandler(request => { Console.WriteLine(request.Message); }); - - socketMessageLayer.AddPacketHandler(request => - { - screenshotEvent.Set(); - }); - - var runTask = Task.Run(() => socketMessageLayer.MessageLoop()); - - var cmd = platform == PlatformType.Windows ? Path.Combine(Environment.CurrentDirectory, gamePath, "Bin\\Windows\\Debug\\win-x64", gameName + ".Windows.exe") : ""; - - socketMessageLayer.Send(new TestRegistrationRequest - { - Platform = (int)platform, Tester = true, Cmd = cmd, GameAssembly = gameName + ".Game" - }).Wait(); - - // Wait up to one minute - var waitMs = 60 * 1000; - switch (platform) - { - case PlatformType.Android: - waitMs *= 2; - break; - case PlatformType.iOS: - waitMs *= 2; - break; - } - - if (!ev.WaitOne(waitMs)) - { - socketMessageLayer.Send(new TestAbortedRequest()).Wait(); - throw new Exception("Time out while launching the game"); - } - - if (!success) - { - throw new Exception("Failed: " + message); - } - - Console.WriteLine(@"Game started. (message: " + message + @")"); - } - - public void KeyPress(Keys key, TimeSpan timeDown) - { - socketMessageLayer.Send(new KeySimulationRequest { Down = true, Key = key }).Wait(); - Console.WriteLine(@"Simulating key down {0}.", key); - - Thread.Sleep(timeDown); - - socketMessageLayer.Send(new KeySimulationRequest { Down = false, Key = key }).Wait(); - Console.WriteLine(@"Simulating key up {0}.", key); - } - - public void Tap(Vector2 coords, TimeSpan timeDown) - { - socketMessageLayer.Send(new TapSimulationRequest { EventType = PointerEventType.Pressed, Coords = coords }).Wait(); - Console.WriteLine(@"Simulating tap down {0}.", coords); - - Thread.Sleep(timeDown); - - socketMessageLayer.Send(new TapSimulationRequest { EventType = PointerEventType.Released, Coords = coords, Delta = timeDown }).Wait(); - Console.WriteLine(@"Simulating tap up {0}.", coords); - } - - public void Drag(Vector2 from, Vector2 target, TimeSpan timeToTarget, TimeSpan timeDown) - { - socketMessageLayer.Send(new TapSimulationRequest { EventType = PointerEventType.Pressed, Coords = from }).Wait(); - Console.WriteLine(@"Simulating tap down {0}.", from); - - //send 15 events per second? - var sleepTime = TimeSpan.FromMilliseconds(1000/15.0); - var watch = Stopwatch.StartNew(); - var start = watch.Elapsed; - var end = watch.Elapsed + timeToTarget; - Vector2 prev = from; - while (true) - { - if (watch.Elapsed > timeToTarget) - { - break; - } - - float factor = (watch.Elapsed.Ticks - start.Ticks)/(float)(end.Ticks - start.Ticks); - - var current = Vector2.Lerp(from, target, factor); - - var delta = current - prev; - - socketMessageLayer.Send(new TapSimulationRequest { EventType = PointerEventType.Moved, Coords = current, Delta = sleepTime, CoordsDelta = delta }).Wait(); - Console.WriteLine(@"Simulating tap update {0}.", current); - - prev = current; - - Thread.Sleep(sleepTime); - } - - Thread.Sleep(timeDown); - - socketMessageLayer.Send(new TapSimulationRequest { EventType = PointerEventType.Released, Coords = target, Delta = watch.Elapsed, CoordsDelta = target - from }).Wait(); - Console.WriteLine(@"Simulating tap up {0}.", target); - } - - public void TakeScreenshot() - { - socketMessageLayer.Send(new ScreenshotRequest { Filename = gamePath + "\\..\\screenshots\\" + gameName + "_" + platformName + "_" + screenShots + ".png" }).Wait(); - Console.WriteLine(@"Screenshot requested."); - screenShots++; - if (!screenshotEvent.WaitOne(10000)) - { - throw new Exception(@"Failed to store screenshot."); - } - } - - public void Wait(TimeSpan sleepTime) - { - Thread.Sleep(sleepTime); - } - - public void Dispose() - { - Console.WriteLine(@"Ending the test."); - socketMessageLayer.Send(new TestEndedRequest()).Wait(); - } - } -} - -#endif diff --git a/sources/engine/Stride.Games.Testing/GameTestingSystem.cs b/sources/engine/Stride.Games.Testing/GameTestingSystem.cs deleted file mode 100644 index 42718570d2..0000000000 --- a/sources/engine/Stride.Games.Testing/GameTestingSystem.cs +++ /dev/null @@ -1,153 +0,0 @@ -// 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 Stride.Core; -using Stride.Engine; -using Stride.Engine.Network; -using Stride.Games.Testing.Requests; -using Stride.Graphics; -using Stride.Input; -using System; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using Stride.Graphics.Regression; - -namespace Stride.Games.Testing -{ - /// - /// This game system will be automatically injected by the Module initialized when included in the build processing via msbuild - /// The purpose is to simulate events within the game process and report errors and such to the GameTestingClient - /// - internal class GameTestingSystem : GameSystemBase - { - public static bool Initialized; - - private readonly ConcurrentQueue drawActions = new ConcurrentQueue(); - private SocketMessageLayer socketMessageLayer; - - private InputSourceSimulated inputSourceSimulated; - private KeyboardSimulated keyboardSimulated; - private MouseSimulated mouseSimulated; - - public GameTestingSystem(IServiceRegistry registry) : base(registry) - { - DrawOrder = int.MaxValue; - Enabled = true; - Visible = true; - - // Switch to simulated input - var input = registry.GetSafeServiceAs(); - input.Sources.Clear(); - input.Sources.Add(inputSourceSimulated = new InputSourceSimulated()); - keyboardSimulated = inputSourceSimulated.AddKeyboard(); - mouseSimulated = inputSourceSimulated.AddMouse(); - } - - public override async void Initialize() - { - var game = (Game)Game; - - var url = $"/service/Stride.SamplesTestServer/{StrideVersion.NuGetVersion}/Stride.SamplesTestServer.exe"; - - var socketContext = await RouterClient.RequestServer(url); - - socketMessageLayer = new SocketMessageLayer(socketContext, false); - - socketMessageLayer.AddPacketHandler(request => - { - drawActions.Enqueue(() => - { - if (request.Down) - { - keyboardSimulated.SimulateDown(request.Key); - } - else - { - keyboardSimulated.SimulateUp(request.Key); - } - }); - }); - - socketMessageLayer.AddPacketHandler(request => - { - drawActions.Enqueue(() => - { - mouseSimulated.SimulatePointer(request.EventType, request.Coords); - }); - }); - - socketMessageLayer.AddPacketHandler(request => - { - drawActions.Enqueue(() => - { - SaveTexture(game.GraphicsDevice.Presenter.BackBuffer, request.Filename); - }); - }); - - socketMessageLayer.AddPacketHandler(request => - { - socketMessageLayer.Context.Dispose(); - game.Exit(); - Quit(); - }); - - var t = Task.Run(() => socketMessageLayer.MessageLoop()); - - drawActions.Enqueue(async () => - { - await socketMessageLayer.Send(new TestRegistrationRequest { GameAssembly = game.Settings.PackageName, Tester = false, Platform = (int)Platform.Type }); - }); - - Initialized = true; - - Console.WriteLine(@"Test initialized, waiting to start..."); - } - - public override void Draw(GameTime gameTime) - { - Action action; - if (drawActions.TryDequeue(out action)) - { - action(); - } - } - - private void SaveTexture(Texture texture, string filename) - { - using (var image = texture.GetDataAsImage(Game.GraphicsContext.CommandList)) - { - //Send to server and store to disk - var imageData = new TestResultImage { Frame = "0", Image = image, TestName = "" }; - var payload = new ScreenShotPayload { FileName = filename }; - var resultFileStream = new MemoryStream(); - var writer = new BinaryWriter(resultFileStream); - imageData.Write(writer); - - Task.Run(() => - { - payload.Data = resultFileStream.ToArray(); - payload.Size = payload.Data.Length; - socketMessageLayer.Send(payload).Wait(); - resultFileStream.Dispose(); - }); - } - } - -#if STRIDE_PLATFORM_IOS - [DllImport("__Internal", EntryPoint = "exit")] - public static extern void exit(int status); -#endif - - public static void Quit() - { -#if STRIDE_PLATFORM_ANDROID - global::Android.OS.Process.KillProcess(global::Android.OS.Process.MyPid()); -#elif STRIDE_PLATFORM_IOS - exit(0); -#endif - } - } -} diff --git a/sources/engine/Stride.Games.Testing/Module.cs b/sources/engine/Stride.Games.Testing/Module.cs deleted file mode 100644 index 11c124d8c9..0000000000 --- a/sources/engine/Stride.Games.Testing/Module.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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; -using System.Threading.Tasks; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Engine; - -namespace Stride.Games.Testing -{ - //This is how we inject the assembly to run automatically at game start, paired with Stride.targets and the msbuild property StrideAutoTesting - internal class Module - { - [ModuleInitializer] - public static void Initialize() - { - //Quit after 10 seconds anyway! - Task.Run(async () => - { - await Task.Delay(20000); - if (!GameTestingSystem.Initialized) - { - Console.WriteLine(@"FATAL: Test launch timeout. Aborting."); - GameTestingSystem.Quit(); - } - }); - - //quit after 10 seconds in any case - Game.GameStarted += (sender, args) => - { - var game = (Game)sender; - var testingSystem = new GameTestingSystem(game.Services); - game.GameSystems.Add(testingSystem); - }; - } - } -} diff --git a/sources/engine/Stride.Games.Testing/Properties/AssemblyInfo.cs b/sources/engine/Stride.Games.Testing/Properties/AssemblyInfo.cs deleted file mode 100644 index dc4a9b45f8..0000000000 --- a/sources/engine/Stride.Games.Testing/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -// 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.Runtime.InteropServices; - -// 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. - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("b84ecb15-5e3f-4bd1-ab87-333bae9b70f9")] - -[assembly: InternalsVisibleTo("Stride.SamplesTestServer")] diff --git a/sources/engine/Stride.Games.Testing/Requests/KeySimulationRequest.cs b/sources/engine/Stride.Games.Testing/Requests/KeySimulationRequest.cs deleted file mode 100644 index 9ea21c1410..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/KeySimulationRequest.cs +++ /dev/null @@ -1,15 +0,0 @@ -// 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 Stride.Core; -using Stride.Input; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class KeySimulationRequest : TestRequestBase - { - public Keys Key; - public bool Down; - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/LogRequest.cs b/sources/engine/Stride.Games.Testing/Requests/LogRequest.cs deleted file mode 100644 index ac202f7c71..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/LogRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class LogRequest : TestRequestBase - { - public string Message; - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/ScreenShotPayload.cs b/sources/engine/Stride.Games.Testing/Requests/ScreenShotPayload.cs deleted file mode 100644 index 205abedd27..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/ScreenShotPayload.cs +++ /dev/null @@ -1,15 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class ScreenShotPayload : TestRequestBase - { - public int Size; - public byte[] Data; - public string FileName; - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/ScreenshotRequest.cs b/sources/engine/Stride.Games.Testing/Requests/ScreenshotRequest.cs deleted file mode 100644 index 5c66f2a574..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/ScreenshotRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class ScreenshotRequest : TestRequestBase - { - public string Filename; - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/ScreenshotStored.cs b/sources/engine/Stride.Games.Testing/Requests/ScreenshotStored.cs deleted file mode 100644 index e79c9d1d27..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/ScreenshotStored.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class ScreenshotStored : TestRequestBase - { - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/StatusMessageRequest.cs b/sources/engine/Stride.Games.Testing/Requests/StatusMessageRequest.cs deleted file mode 100644 index 33db3a1206..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/StatusMessageRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class StatusMessageRequest : TestRequestBase - { - public bool Error; - public string Message; - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/TapSimulationRequest.cs b/sources/engine/Stride.Games.Testing/Requests/TapSimulationRequest.cs deleted file mode 100644 index d97df99f6e..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/TapSimulationRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -// 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; -using Stride.Core; -using Stride.Core.Mathematics; -using Stride.Input; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class TapSimulationRequest : TestRequestBase - { - public PointerEventType EventType; - public TimeSpan Delta; - public Vector2 Coords; - public Vector2 CoordsDelta; - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/TestAbortedRequest.cs b/sources/engine/Stride.Games.Testing/Requests/TestAbortedRequest.cs deleted file mode 100644 index 6b4aa39a0c..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/TestAbortedRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class TestAbortedRequest : TestRequestBase - { - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/TestEndedRequest.cs b/sources/engine/Stride.Games.Testing/Requests/TestEndedRequest.cs deleted file mode 100644 index 4f5f7405d3..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/TestEndedRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class TestEndedRequest : TestRequestBase - { - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/TestRegistrationRequest.cs b/sources/engine/Stride.Games.Testing/Requests/TestRegistrationRequest.cs deleted file mode 100644 index eab03de066..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/TestRegistrationRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class TestRegistrationRequest : TestRequestBase - { - public string Cmd; - public string GameAssembly; - public int Platform; - public bool Tester; - } -} diff --git a/sources/engine/Stride.Games.Testing/Requests/TestRequestBase.cs b/sources/engine/Stride.Games.Testing/Requests/TestRequestBase.cs deleted file mode 100644 index 9191790873..0000000000 --- a/sources/engine/Stride.Games.Testing/Requests/TestRequestBase.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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 Stride.Core; - -namespace Stride.Games.Testing.Requests -{ - [DataContract] - internal class TestRequestBase - { - } -} diff --git a/sources/engine/Stride.Games/GraphicsDeviceManager.cs b/sources/engine/Stride.Games/GraphicsDeviceManager.cs index 40d2ecd42a..3595da973b 100644 --- a/sources/engine/Stride.Games/GraphicsDeviceManager.cs +++ b/sources/engine/Stride.Games/GraphicsDeviceManager.cs @@ -191,6 +191,13 @@ private void GameOnWindowCreated(object sender, EventArgs eventArgs) /// public string RequiredAdapterUid { get; set; } + /// + /// When set, the requested back-buffer size is honoured as-is and any clamp to the host + /// window's client area is skipped (both in + /// and in the 's swap-chain creation). + /// + public bool SkipBackBufferClampToWindow { get; set; } + /// /// Gets or sets the preferred color space for the Back-Buffers. /// @@ -1185,6 +1192,7 @@ void ChangeOrCreateDevice() // Find the best device configuration based on the current settings var graphicsDeviceInformation = FindBestDevice(forceCreate); + graphicsDeviceInformation.PresentationParameters.SkipBackBufferClampToWindow = SkipBackBufferClampToWindow; // Give a chance to the game to modify the device settings before the device is created or reset OnPreparingDeviceSettings(this, new PreparingDeviceSettingsEventArgs(graphicsDeviceInformation)); diff --git a/sources/engine/Stride.Graphics/PresentationParameters.cs b/sources/engine/Stride.Graphics/PresentationParameters.cs index cf4c0a8aa5..aebe49ab92 100644 --- a/sources/engine/Stride.Graphics/PresentationParameters.cs +++ b/sources/engine/Stride.Graphics/PresentationParameters.cs @@ -194,6 +194,12 @@ public sealed class PresentationParameters : IEquatable /// public ColorSpace ColorSpace; + /// + /// When set, the presenter honours / + /// as-is and skips any clamp to the host window's client area. + /// + public bool SkipBackBufferClampToWindow; + /// /// The color space type used for the Graphics Presenter output. /// diff --git a/sources/engine/Stride.Graphics/Vulkan/SwapChainGraphicsPresenter.Vulkan.cs b/sources/engine/Stride.Graphics/Vulkan/SwapChainGraphicsPresenter.Vulkan.cs index 12374b80a0..75eac9709b 100644 --- a/sources/engine/Stride.Graphics/Vulkan/SwapChainGraphicsPresenter.Vulkan.cs +++ b/sources/engine/Stride.Graphics/Vulkan/SwapChainGraphicsPresenter.Vulkan.cs @@ -432,8 +432,11 @@ private unsafe void CreateSwapChain(int width, int height, PixelFormat desiredFo // Create swapchain GraphicsDevice.NativeInstanceApi.vkGetPhysicalDeviceSurfaceCapabilitiesKHR(GraphicsDevice.NativePhysicalDevice, surface, out var surfaceCapabilities); - Description.BackBufferWidth = (int)surfaceCapabilities.currentExtent.width; - Description.BackBufferHeight = (int)surfaceCapabilities.currentExtent.height; + if (!Description.SkipBackBufferClampToWindow) + { + Description.BackBufferWidth = (int)surfaceCapabilities.currentExtent.width; + Description.BackBufferHeight = (int)surfaceCapabilities.currentExtent.height; + } // Buffer count uint desiredImageCount = Math.Max(surfaceCapabilities.minImageCount, 2); diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets index 28f3efc3f9..8b40876107 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets @@ -169,8 +169,11 @@ - - + + $(StrideRoot)bin\packages\ diff --git a/sources/tools/Stride.SamplesTestServer/Program.cs b/sources/tools/Stride.SamplesTestServer/Program.cs deleted file mode 100644 index c4f42c4dc2..0000000000 --- a/sources/tools/Stride.SamplesTestServer/Program.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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; -using System.Diagnostics; -using System.Threading; -using Stride.Engine.Network; - -namespace Stride.SamplesTestServer -{ - class Program - { - static void Main(string[] args) - { - var samplesServer = new SamplesTestServer(); - try - { - samplesServer.TryConnect("127.0.0.1", RouterClient.DefaultPort).Wait(); - } - catch - { - return; - } - - // Forbid process to terminate (unless ctrl+c) - while (true) - { - Console.Read(); - Thread.Sleep(100); - } - } - } -} diff --git a/sources/tools/Stride.SamplesTestServer/Properties/AssemblyInfo.cs b/sources/tools/Stride.SamplesTestServer/Properties/AssemblyInfo.cs deleted file mode 100644 index c1096b872b..0000000000 --- a/sources/tools/Stride.SamplesTestServer/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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.Runtime.InteropServices; - -// 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. - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("75d71310-ecf7-4592-9e35-3fe540040982")] - diff --git a/sources/tools/Stride.SamplesTestServer/SamplesTestServer.cs b/sources/tools/Stride.SamplesTestServer/SamplesTestServer.cs deleted file mode 100644 index 75b6a00bdc..0000000000 --- a/sources/tools/Stride.SamplesTestServer/SamplesTestServer.cs +++ /dev/null @@ -1,449 +0,0 @@ -// 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 Stride.Core; -using Stride.ConnectionRouter; -using Stride.Engine.Network; -using Stride.Games.Testing; -using Stride.Games.Testing.Requests; -using Stride.Graphics; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Stride.Core.Extensions; -using Stride.Graphics.Regression; - -namespace Stride.SamplesTestServer -{ - public class SamplesTestServer : RouterServiceServer - { - private class TestPair - { - public SocketMessageLayer TesterSocket; - public SocketMessageLayer GameSocket; - public string GameName; - public Process Process; - public Action TestEndAction; - } - - private readonly Dictionary processes = new Dictionary(); - - private readonly Dictionary testerToGame = new Dictionary(); - private readonly Dictionary gameToTester = new Dictionary(); - - private SocketMessageLayer currentTester; - private readonly object loggerLock = new object(); - - public SamplesTestServer() : base($"/service/Stride.SamplesTestServer/{StrideVersion.NuGetVersion}/Stride.SamplesTestServer.exe") - { - GameTestingSystem.Initialized = true; - - //start logging the iOS device if we have the proper tools avail - if (IosTracker.CanProxy()) - { - var loggerProcess = Process.Start(new ProcessStartInfo($"idevicesyslog.exe", "-d") - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - }); - - if (loggerProcess != null) - { - loggerProcess.OutputDataReceived += (sender, args) => - { - try - { - lock (loggerLock) - { - currentTester?.Send(new LogRequest { Message = $"STDIO: {args.Data}" }).Wait(); - } - } - catch - { - } - }; - - loggerProcess.ErrorDataReceived += (sender, args) => - { - try - { - lock (loggerLock) - { - currentTester?.Send(new LogRequest { Message = $"STDERR: {args.Data}" }).Wait(); - } - } - catch - { - } - }; - - loggerProcess.BeginOutputReadLine(); - loggerProcess.BeginErrorReadLine(); - - new AttachedChildProcessJob(loggerProcess); - } - } - - //Start also adb in case of android device - var adbPath = AndroidDeviceEnumerator.GetAdbPath(); - if (!string.IsNullOrEmpty(adbPath) && AndroidDeviceEnumerator.ListAndroidDevices().Length > 0) - { - //clear the log first - ShellHelper.RunProcessAndGetOutput("cmd.exe", $"/C {adbPath} logcat -c"); - - //start logger - var loggerProcess = Process.Start(new ProcessStartInfo("cmd.exe", $"/C {adbPath} logcat") - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - }); - - if (loggerProcess != null) - { - loggerProcess.OutputDataReceived += (sender, args) => - { - try - { - currentTester?.Send(new LogRequest { Message = $"STDIO: {args.Data}" }).Wait(); - } - catch - { - } - }; - - loggerProcess.ErrorDataReceived += (sender, args) => - { - try - { - currentTester?.Send(new LogRequest { Message = $"STDERR: {args.Data}" }).Wait(); - } - catch - { - } - }; - - loggerProcess.BeginOutputReadLine(); - loggerProcess.BeginErrorReadLine(); - - new AttachedChildProcessJob(loggerProcess); - } - } - } - - protected override async void HandleClient(SimpleSocket clientSocket, string url) - { - await AcceptConnection(clientSocket); - - var socketMessageLayer = new SocketMessageLayer(clientSocket, true); - - socketMessageLayer.AddPacketHandler(async request => - { - if (request.Tester) - { - switch (request.Platform) - { - case (int)PlatformType.Windows: - { - Process process = null; - var debugInfo = ""; - try - { - var workingDir = Path.GetDirectoryName(request.Cmd); - if (workingDir != null) - { - var start = new ProcessStartInfo - { - WorkingDirectory = workingDir, - FileName = request.Cmd - }; - start.UseShellExecute = false; - start.RedirectStandardError = true; - start.RedirectStandardOutput = true; - - debugInfo = "Starting process " + start.FileName + " with path " + start.WorkingDirectory; - await socketMessageLayer.Send(new LogRequest { Message = debugInfo }); - process = Process.Start(start); - } - } - catch (Exception ex) - { - await socketMessageLayer.Send(new StatusMessageRequest { Error = true, Message = "Launch exception: " + ex.Message }); - } - - if (process == null) - { - await socketMessageLayer.Send(new StatusMessageRequest { Error = true, Message = "Failed to start game process. " + debugInfo }); - } - else - { - process.OutputDataReceived += async (sender, args) => - { - try - { - if (args.Data != null) - await socketMessageLayer.Send(new LogRequest { Message = $"STDIO: {args.Data}" }); - } - catch - { - } - }; - - process.ErrorDataReceived += async (sender, args) => - { - try - { - if (args.Data != null) - await socketMessageLayer.Send(new LogRequest { Message = $"STDERR: {args.Data}" }); - } - catch - { - } - }; - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - var currenTestPair = new TestPair { TesterSocket = socketMessageLayer, GameName = request.GameAssembly, Process = process }; - lock (processes) - { - processes[request.GameAssembly] = currenTestPair; - testerToGame[socketMessageLayer] = currenTestPair; - } - await socketMessageLayer.Send(new LogRequest { Message = "Process created, id: " + process.Id.ToString() }); - } - break; - } - case (int)PlatformType.Android: - { - Process process = null; - try - { - process = Process.Start("cmd.exe", $"/C adb shell monkey -p {request.GameAssembly}.{request.GameAssembly} -c android.intent.category.LAUNCHER 1"); - } - catch (Exception ex) - { - await socketMessageLayer.Send(new StatusMessageRequest { Error = true, Message = "Launch exception: " + ex.Message }); - } - - if (process == null) - { - await socketMessageLayer.Send(new StatusMessageRequest { Error = true, Message = "Failed to start game process." }); - } - else - { - lock (loggerLock) - { - currentTester = socketMessageLayer; - } - - var currenTestPair = new TestPair - { - TesterSocket = socketMessageLayer, - GameName = request.GameAssembly, - Process = process, - TestEndAction = () => -{ - // force stop - only works for Android 3.0 and above. - Process.Start("cmd.exe", $"/C adb shell am force-stop {request.GameAssembly}.{request.GameAssembly}"); -} - }; - lock (processes) - { - processes[request.GameAssembly] = currenTestPair; - testerToGame[socketMessageLayer] = currenTestPair; - } - await socketMessageLayer.Send(new LogRequest { Message = "Process created, id: " + process.Id.ToString() }); - } - break; - } - case (int)PlatformType.iOS: - { - Process process = null; - var debugInfo = ""; - try - { - Thread.Sleep(5000); //ios processes might be slow to close, we must make sure that we start clean - var start = new ProcessStartInfo - { - FileName = $"idevicedebug.exe", - Arguments = $"run com.your-company.{request.GameAssembly}", - UseShellExecute = false - }; - debugInfo = "Starting process " + start.FileName + " with path " + start.WorkingDirectory; - process = Process.Start(start); - } - catch (Exception ex) - { - await socketMessageLayer.Send(new StatusMessageRequest { Error = true, Message = $"Launch exception: {ex.Message} info: {debugInfo}" }); - } - - if (process == null) - { - await socketMessageLayer.Send(new StatusMessageRequest { Error = true, Message = "Failed to start game process. " + debugInfo }); - } - else - { - lock (loggerLock) - { - currentTester = socketMessageLayer; - } - - var currenTestPair = new TestPair { TesterSocket = socketMessageLayer, GameName = request.GameAssembly, Process = process }; - lock (processes) - { - processes[request.GameAssembly] = currenTestPair; - testerToGame[socketMessageLayer] = currenTestPair; - } - await socketMessageLayer.Send(new LogRequest { Message = "Process created, id: " + process.Id.ToString() }); - } - break; - } - } - } - else //Game process - { - TestPair pair; - lock (processes) - { - if (!processes.TryGetValue(request.GameAssembly, out pair)) return; - - pair.GameSocket = socketMessageLayer; - - testerToGame[pair.TesterSocket] = pair; - gameToTester[pair.GameSocket] = pair; - } - - await pair.TesterSocket.Send(new StatusMessageRequest { Error = false, Message = "Start" }); - - Console.WriteLine($"Starting test {request.GameAssembly}"); - } - }); - - socketMessageLayer.AddPacketHandler(async request => - { - TestPair game; - lock (processes) - { - game = testerToGame[socketMessageLayer]; - } - await game.GameSocket.Send(request); - }); - - socketMessageLayer.AddPacketHandler(async request => - { - TestPair game; - lock (processes) - { - game = testerToGame[socketMessageLayer]; - } - await game.GameSocket.Send(request); - }); - - socketMessageLayer.AddPacketHandler(async request => - { - TestPair game; - lock (processes) - { - game = testerToGame[socketMessageLayer]; - } - await game.GameSocket.Send(request); - }); - - socketMessageLayer.AddPacketHandler(async request => - { - TestPair game; - lock (processes) - { - game = testerToGame[socketMessageLayer]; - } - await game.GameSocket.Send(request); - - lock (processes) - { - testerToGame.Remove(socketMessageLayer); - gameToTester.Remove(game.GameSocket); - processes.Remove(game.GameName); - } - - socketMessageLayer.Context.Dispose(); - game.GameSocket.Context.Dispose(); - - game.Process.Kill(); - game.Process.Dispose(); - - lock (loggerLock) - { - currentTester = null; - } - - game.TestEndAction?.Invoke(); - - Console.WriteLine($"Finished test {game.GameName}"); - }); - - socketMessageLayer.AddPacketHandler(request => - { - TestPair game; - lock (processes) - { - game = testerToGame[socketMessageLayer]; - testerToGame.Remove(socketMessageLayer); - processes.Remove(game.GameName); - } - - socketMessageLayer.Context.Dispose(); - - game.Process.Kill(); - game.Process.Dispose(); - - lock (loggerLock) - { - currentTester = null; - } - - game.TestEndAction?.Invoke(); - - Console.WriteLine($"Aborted test {game.GameName}"); - }); - - socketMessageLayer.AddPacketHandler(async request => - { - TestPair tester; - lock (processes) - { - tester = gameToTester[socketMessageLayer]; - } - - var imageData = new TestResultImage(); - var stream = new MemoryStream(request.Data); - imageData.Read(new BinaryReader(stream)); - await stream.DisposeAsync(); - // Ensure directory exists - Directory.CreateDirectory(Path.GetDirectoryName(request.FileName)); - var resultFileStream = File.OpenWrite(request.FileName); - imageData.Image.Save(resultFileStream, ImageFileType.Png); - await resultFileStream.DisposeAsync(); - - await tester.TesterSocket.Send(new ScreenshotStored()); - }); - - Task.Run(async () => - { - try - { - await socketMessageLayer.MessageLoop(); - } - catch - { - } - }); - } - } -} diff --git a/sources/tools/Stride.SamplesTestServer/Stride.SamplesTestServer.csproj b/sources/tools/Stride.SamplesTestServer/Stride.SamplesTestServer.csproj deleted file mode 100644 index 1da3bfbd20..0000000000 --- a/sources/tools/Stride.SamplesTestServer/Stride.SamplesTestServer.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - Exe - $(StrideEditorTargetFramework) - true - - - - Properties\SharedAssemblyInfo.cs - - - - - - - - - true - Never - - - - - - - - - diff --git a/sources/tools/Stride.SamplesTestServer/app.config b/sources/tools/Stride.SamplesTestServer/app.config deleted file mode 100644 index 99ddf3e08e..0000000000 --- a/sources/tools/Stride.SamplesTestServer/app.config +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..add48c3ed6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,8 @@ +# tests/ + +Gold/baseline images for image-comparison regression tests. Test code lives next to whatever runs the comparison; this tree only holds the reference PNGs (and, where convenient, the small fixture script that drives capture). + +## Subtrees + +- [GPU-TESTING.md](GPU-TESTING.md) — engine-level GPU regression tests (rendering primitives, shaders, particles, etc.). Per-API gold images under `Stride..Tests/..png`. +- [Stride.Samples.Tests/](Stride.Samples.Tests/README.md) — end-to-end sample screenshot tests. Per-sample fixture + `/.png` golds. diff --git a/tests/Stride.Samples.Tests/AnimatedModel.cs b/tests/Stride.Samples.Tests/AnimatedModel.cs new file mode 100644 index 0000000000..35bfd090d7 --- /dev/null +++ b/tests/Stride.Samples.Tests/AnimatedModel.cs @@ -0,0 +1,31 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; + +namespace AnimatedModel.Tests; + +[ScreenshotTest(TemplateId = "99371864-55BD-4C78-B25C-42471F977540")] +public class AnimatedModelScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + + // ButtonIdle / ButtonRun live in a left-aligned vertical StackPanel; small text-only + // buttons at the very top-left corner of the portrait viewport. + await ctx.Tap(new Vector2(0.05f, 0.02f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("idle", claudeFallback: "Knight character standing in idle stance (legs roughly together, arms at sides). Exact pose phase varies between runs."); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + + await ctx.Tap(new Vector2(0.05f, 0.05f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("run", claudeFallback: "Knight character in a running pose (legs apart mid-stride). Exact pose phase varies between runs."); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/AnimatedModel/idle.png b/tests/Stride.Samples.Tests/AnimatedModel/idle.png new file mode 100644 index 0000000000..d0f3cfd2cc --- /dev/null +++ b/tests/Stride.Samples.Tests/AnimatedModel/idle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bc574a5ea410d4920c16cc66a0fc7c29ae8978e29bf31df350ecc9c5c4830ff +size 234302 diff --git a/tests/Stride.Samples.Tests/AnimatedModel/run.png b/tests/Stride.Samples.Tests/AnimatedModel/run.png new file mode 100644 index 0000000000..1bc5073ae5 --- /dev/null +++ b/tests/Stride.Samples.Tests/AnimatedModel/run.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b81f2bc3a8d2dbc077e5cbaf10d78b5d60daec46da19bf0987e409c7a4b9b4c +size 223741 diff --git a/tests/Stride.Samples.Tests/CustomEffect.cs b/tests/Stride.Samples.Tests/CustomEffect.cs new file mode 100644 index 0000000000..dc9ca10dd3 --- /dev/null +++ b/tests/Stride.Samples.Tests/CustomEffect.cs @@ -0,0 +1,19 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Games.AutoTesting; + +namespace CustomEffect.Tests; + +[ScreenshotTest(TemplateId = "16476A4C-C131-4F48-865A-288EC7D5445F")] +public class CustomEffectScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("steady"); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/CustomEffect/steady.png b/tests/Stride.Samples.Tests/CustomEffect/steady.png new file mode 100644 index 0000000000..df0cbd2e1a --- /dev/null +++ b/tests/Stride.Samples.Tests/CustomEffect/steady.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83f6a4c4c0c600c75b2ad3e508fd01c97277e8f7f075d1a515c7e8e795df8b19 +size 236945 diff --git a/tests/Stride.Samples.Tests/FirstPersonShooter.cs b/tests/Stride.Samples.Tests/FirstPersonShooter.cs new file mode 100644 index 0000000000..93bd725c98 --- /dev/null +++ b/tests/Stride.Samples.Tests/FirstPersonShooter.cs @@ -0,0 +1,25 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace FirstPersonShooter.Tests; + +[ScreenshotTest(TemplateId = "B12AF970-1F11-4BC8-9571-3B4DA9E20F05")] +public class FirstPersonShooterScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.PressKey(Keys.Space, TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.Screenshot("after-jump"); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/FirstPersonShooter/after-jump.png b/tests/Stride.Samples.Tests/FirstPersonShooter/after-jump.png new file mode 100644 index 0000000000..4b8de8d553 --- /dev/null +++ b/tests/Stride.Samples.Tests/FirstPersonShooter/after-jump.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06da7987daf029967e3870dd17d52d63bed3526301ee1aa5afe4a9f65b029dec +size 461361 diff --git a/tests/Stride.Samples.Tests/GameMenu.cs b/tests/Stride.Samples.Tests/GameMenu.cs new file mode 100644 index 0000000000..b7f2df4467 --- /dev/null +++ b/tests/Stride.Samples.Tests/GameMenu.cs @@ -0,0 +1,33 @@ +// Navigate the main menu. +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; + +namespace GameMenu.Tests; + +[ScreenshotTest(TemplateId = "7ac2c705-6240-4ddc-af63-fc438d10f4de")] +public class GameMenuScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("main-menu"); + + await ctx.Tap(new Vector2(0.4765625f, 0.8389084f), TimeSpan.FromMilliseconds(250)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(250)); + await ctx.Screenshot("after-tap-1"); + + await ctx.Tap(new Vector2(0.6609375f, 0.7315141f), TimeSpan.FromMilliseconds(250)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(250)); + await ctx.Screenshot("after-tap-2"); + + await ctx.Tap(new Vector2(0.5390625f, 0.7764084f), TimeSpan.FromMilliseconds(250)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("after-tap-3"); + + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/GameMenu/after-tap-1.png b/tests/Stride.Samples.Tests/GameMenu/after-tap-1.png new file mode 100644 index 0000000000..8af3f6cf01 --- /dev/null +++ b/tests/Stride.Samples.Tests/GameMenu/after-tap-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cc4745061f9a9fcc20f2b4de277d464611b171cd74b7eaf2da5d8449a21cab7 +size 543145 diff --git a/tests/Stride.Samples.Tests/GameMenu/after-tap-2.png b/tests/Stride.Samples.Tests/GameMenu/after-tap-2.png new file mode 100644 index 0000000000..e2f541c62c --- /dev/null +++ b/tests/Stride.Samples.Tests/GameMenu/after-tap-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a45d42aa721aeb02f5231554a6fb6ecc66e2c53375fac66f71d5c86bfbaa74f2 +size 524712 diff --git a/tests/Stride.Samples.Tests/GameMenu/after-tap-3.png b/tests/Stride.Samples.Tests/GameMenu/after-tap-3.png new file mode 100644 index 0000000000..613305342c --- /dev/null +++ b/tests/Stride.Samples.Tests/GameMenu/after-tap-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4ec63a396363ec0d0c5ae7ee2a5a4047cc024144f90c8c665f1d5743404e3ef +size 638169 diff --git a/tests/Stride.Samples.Tests/GameMenu/main-menu.png b/tests/Stride.Samples.Tests/GameMenu/main-menu.png new file mode 100644 index 0000000000..ae3dc290f0 --- /dev/null +++ b/tests/Stride.Samples.Tests/GameMenu/main-menu.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cf875e421bc70aee1abf782164e7bb3c5d5c01d5676575b078ca7922a6a02dd +size 436647 diff --git a/tests/Stride.Samples.Tests/GravitySensor.cs b/tests/Stride.Samples.Tests/GravitySensor.cs new file mode 100644 index 0000000000..03d4533b72 --- /dev/null +++ b/tests/Stride.Samples.Tests/GravitySensor.cs @@ -0,0 +1,39 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace GravitySensor.Tests; + +[ScreenshotTest(TemplateId = "7174D040-C0FB-4D5C-8170-3411AD8AA4C2")] +public class GravitySensorScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + // GravityScript only applies the directional force while the key is held (Input.IsKeyDown), + // so the key has to stay down across the settle-wait and the screenshot. + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + + ctx.PressKey(Keys.Down); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2500)); + await ctx.Screenshot("down"); + ctx.ReleaseKey(Keys.Down); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + + ctx.PressKey(Keys.Up); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2500)); + await ctx.Screenshot("up"); + ctx.ReleaseKey(Keys.Up); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + + ctx.PressKey(Keys.Left); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2500)); + await ctx.Screenshot("left"); + ctx.ReleaseKey(Keys.Left); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/GravitySensor/down.png b/tests/Stride.Samples.Tests/GravitySensor/down.png new file mode 100644 index 0000000000..d8605ee99d --- /dev/null +++ b/tests/Stride.Samples.Tests/GravitySensor/down.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53aaddfcca32103bc91fa8a64b0f52f67a80e1fd8b0acccbde33d20fe76ff11b +size 112537 diff --git a/tests/Stride.Samples.Tests/GravitySensor/left.png b/tests/Stride.Samples.Tests/GravitySensor/left.png new file mode 100644 index 0000000000..1995e14f01 --- /dev/null +++ b/tests/Stride.Samples.Tests/GravitySensor/left.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:304af0936548d758765d5742c4a084c63a75e254ae12ddc1cfeb070dc141ab23 +size 119083 diff --git a/tests/Stride.Samples.Tests/GravitySensor/up.png b/tests/Stride.Samples.Tests/GravitySensor/up.png new file mode 100644 index 0000000000..e74ba2f119 --- /dev/null +++ b/tests/Stride.Samples.Tests/GravitySensor/up.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fdddbf6da87212b3c9bfc4b5b22c611f67ad491d2408624d04bd040850e6ec79 +size 113459 diff --git a/tests/Stride.Samples.Tests/JumpyJet.cs b/tests/Stride.Samples.Tests/JumpyJet.cs new file mode 100644 index 0000000000..c4dee4bb88 --- /dev/null +++ b/tests/Stride.Samples.Tests/JumpyJet.cs @@ -0,0 +1,31 @@ +// STRIDE_AUTOTESTING gate is explained in samples/Directory.Build.targets. +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace JumpyJet.Tests; + +[ScreenshotTest(TemplateId = "1C9E733A-16BB-48C3-A4DE-722B61EED994")] +public class JumpyJetScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + // Jump impulse is 6.5 m/s up, gravity -17 m/s² → apex at ~380ms. Capture mid-ascent + // (~150ms post-jump) so the bird is well clear of the start position but pipes haven't + // had time to enter the play area + collide. Whole sequence under ~1s before exit. + const string JumpHint = "Side-scroller bird game. Bird is alive, mid-flight (HUD shows Score, no Retry/Menu overlay). Pipe positions and exact bird Y vary; ignore those."; + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("menu"); + await ctx.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(100)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(100)); + await ctx.Screenshot("started", claudeFallback: JumpHint); + await ctx.PressKey(Keys.Space, TimeSpan.FromMilliseconds(50)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(150)); + await ctx.Screenshot("jumping", claudeFallback: JumpHint); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/JumpyJet/jumping.png b/tests/Stride.Samples.Tests/JumpyJet/jumping.png new file mode 100644 index 0000000000..b811974af5 --- /dev/null +++ b/tests/Stride.Samples.Tests/JumpyJet/jumping.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc19017261c1b330a04d6036899631ac5b76695011ea4e52d95f9591bece28a9 +size 470067 diff --git a/tests/Stride.Samples.Tests/JumpyJet/menu.png b/tests/Stride.Samples.Tests/JumpyJet/menu.png new file mode 100644 index 0000000000..4d5de2b4be --- /dev/null +++ b/tests/Stride.Samples.Tests/JumpyJet/menu.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b312563f03633415268a75624561daf4865713058cc90752abfa593e1630e2e +size 462882 diff --git a/tests/Stride.Samples.Tests/JumpyJet/started.png b/tests/Stride.Samples.Tests/JumpyJet/started.png new file mode 100644 index 0000000000..09073bfad4 --- /dev/null +++ b/tests/Stride.Samples.Tests/JumpyJet/started.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3aa383fcf72386d2564d8b93c3ee2ec8c85740c5d3ebdeeac5736ccfbbc5196d +size 459232 diff --git a/tests/Stride.Samples.Tests/MaterialShader.cs b/tests/Stride.Samples.Tests/MaterialShader.cs new file mode 100644 index 0000000000..2546eb187b --- /dev/null +++ b/tests/Stride.Samples.Tests/MaterialShader.cs @@ -0,0 +1,19 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Games.AutoTesting; + +namespace MaterialShader.Tests; + +[ScreenshotTest(TemplateId = "f80f8a38-c05a-44bd-ab6d-d2a4f1cf4c58")] +public class MaterialShaderScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("steady"); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/MaterialShader/steady.png b/tests/Stride.Samples.Tests/MaterialShader/steady.png new file mode 100644 index 0000000000..c8b04d8bea --- /dev/null +++ b/tests/Stride.Samples.Tests/MaterialShader/steady.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff70fcedc508ef4b00749717ff41774e63402266db76b9dba959d606f9f81f22 +size 251466 diff --git a/tests/Stride.Samples.Tests/ParticlesSample.cs b/tests/Stride.Samples.Tests/ParticlesSample.cs new file mode 100644 index 0000000000..0cad94c4d7 --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample.cs @@ -0,0 +1,33 @@ +// Cycle through 7 demo scenes. +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; + +namespace ParticlesSample.Tests; + +[ScreenshotTest(TemplateId = "35C3FB4D-2A6E-40EB-825E-D4E5670FEE78")] +public class ParticlesSampleScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + // Each scene contains random-emission particle systems whose RNG seed isn't pinned to the + // fixed timestep — a looser threshold absorbs the per-run particle-position jitter. + const float ParticleSceneThreshold = 0.10f; + + const string ParticleHint = "Same particle demo scene composition (same stones/platforms/objects in same arrangement, same particle effect type and rough region). Particle positions, shapes, and counts vary every run; ignore that."; + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("scene-1", ParticleSceneThreshold, claudeFallback: ParticleHint); + + for (int i = 2; i <= 7; i++) + { + await ctx.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(1500)); + await ctx.Screenshot($"scene-{i}", ParticleSceneThreshold, claudeFallback: ParticleHint); + } + + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/ParticlesSample/scene-1.png b/tests/Stride.Samples.Tests/ParticlesSample/scene-1.png new file mode 100644 index 0000000000..470e3ec4da --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample/scene-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ceb02d29e52d0f00ab2ff9f555d6b71e4663b10b21ee8f58b5a9a1605d89ab6 +size 234074 diff --git a/tests/Stride.Samples.Tests/ParticlesSample/scene-2.png b/tests/Stride.Samples.Tests/ParticlesSample/scene-2.png new file mode 100644 index 0000000000..eb0ca4d95b --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample/scene-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f0459a67d8d2fa81c113a7f27a2b1e4f0d4153c604d1d3962e910b8d92f3e95 +size 252587 diff --git a/tests/Stride.Samples.Tests/ParticlesSample/scene-3.png b/tests/Stride.Samples.Tests/ParticlesSample/scene-3.png new file mode 100644 index 0000000000..f93d51b746 --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample/scene-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:289e6d80c00db70dcacd39d56379b15387b3b1b3ec58bdde227b5ad0b5d42829 +size 495936 diff --git a/tests/Stride.Samples.Tests/ParticlesSample/scene-4.png b/tests/Stride.Samples.Tests/ParticlesSample/scene-4.png new file mode 100644 index 0000000000..ae1c11e873 --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample/scene-4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70c243d11bda0ef2d6bb8d012a46bd65f306646c07b7c2cdbdd55794af470540 +size 317022 diff --git a/tests/Stride.Samples.Tests/ParticlesSample/scene-5.png b/tests/Stride.Samples.Tests/ParticlesSample/scene-5.png new file mode 100644 index 0000000000..c3532b2a03 --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample/scene-5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518f5bee825c631b08b6f5fc9869c9d1f3938df08649d07760f78d375ad25986 +size 222140 diff --git a/tests/Stride.Samples.Tests/ParticlesSample/scene-6.png b/tests/Stride.Samples.Tests/ParticlesSample/scene-6.png new file mode 100644 index 0000000000..280cb95d6a --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample/scene-6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:280d27cbe0fb32bbb4b55f1ab6a345ff184ce12fa2399e286413206b337b8b7f +size 465510 diff --git a/tests/Stride.Samples.Tests/ParticlesSample/scene-7.png b/tests/Stride.Samples.Tests/ParticlesSample/scene-7.png new file mode 100644 index 0000000000..141ee2604c --- /dev/null +++ b/tests/Stride.Samples.Tests/ParticlesSample/scene-7.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:429aa0a1cd5e055c88c493f3bfd5fd96e4b6b73311799d5bed756260dfc13f08 +size 190788 diff --git a/tests/Stride.Samples.Tests/PhysicsSample.cs b/tests/Stride.Samples.Tests/PhysicsSample.cs new file mode 100644 index 0000000000..f6756f0bfd --- /dev/null +++ b/tests/Stride.Samples.Tests/PhysicsSample.cs @@ -0,0 +1,29 @@ +// Cycle through 3 demo scenes. +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; + +namespace PhysicsSample.Tests; + +[ScreenshotTest(TemplateId = "d20d150b-d3cb-454e-8c11-620b4c9d393f")] +public class PhysicsSampleScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + const string PhysicsHint = "Same demo scene composition (same objects in roughly the same arrangement and same labelled mode). Physics-driven object positions / animation phases vary every run; ignore those."; + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("constraints", claudeFallback: PhysicsHint); + + for (int i = 2; i <= 3; i++) + { + await ctx.Tap(new Vector2(0.95f, 0.5f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(1500)); + await ctx.Screenshot($"scene-{i}", claudeFallback: PhysicsHint); + } + + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/PhysicsSample/constraints.png b/tests/Stride.Samples.Tests/PhysicsSample/constraints.png new file mode 100644 index 0000000000..c33e5d9cf6 --- /dev/null +++ b/tests/Stride.Samples.Tests/PhysicsSample/constraints.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:967a7a49d3fa8716c4a67e680fcc8772ddf7d60f4634767aa576060ea6d75fb5 +size 137852 diff --git a/tests/Stride.Samples.Tests/PhysicsSample/scene-2.png b/tests/Stride.Samples.Tests/PhysicsSample/scene-2.png new file mode 100644 index 0000000000..d1c7c9e815 --- /dev/null +++ b/tests/Stride.Samples.Tests/PhysicsSample/scene-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b2bf56cba7d3519f419ee717999e9cd099d55c93cbf5e3d60f4650a9d5006b2 +size 133918 diff --git a/tests/Stride.Samples.Tests/PhysicsSample/scene-3.png b/tests/Stride.Samples.Tests/PhysicsSample/scene-3.png new file mode 100644 index 0000000000..702202b038 --- /dev/null +++ b/tests/Stride.Samples.Tests/PhysicsSample/scene-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:623d6bb9c7be2e14a1df5a149272bcf1cf099a61bee8f1a49df172219ae67da6 +size 81200 diff --git a/tests/Stride.Samples.Tests/README.md b/tests/Stride.Samples.Tests/README.md new file mode 100644 index 0000000000..45bcf86a86 --- /dev/null +++ b/tests/Stride.Samples.Tests/README.md @@ -0,0 +1,18 @@ +# Sample screenshot tests + +One `.cs` fixture per directory, baselines as siblings. + +## Add a fixture + +1. Create `.cs` here. Set `[ScreenshotTest(TemplateId = "")]` (look up the template GUID in the sample's `.sdtpl`). +2. Implement `IScreenshotTest.Run` — drive input via `ctx.Tap` / `ctx.PressKey`, capture with `ctx.Screenshot("name")`. See existing fixtures for examples. +3. Run the test once locally (`dotnet test --filter "DisplayName~"`) and copy `screenshot-out//screenshots/*.png` into `tests/Stride.Samples.Tests//` as the baseline. + +## `claudeFallback` + +Optional second-opinion when LPIPS is over threshold. Pass `true` (generic) or a string hint describing what the test cares about ("ignore particle positions, keep scene composition"). Costs an Anthropic call per drift. + +## Updating baselines + +CI: trigger the [Update Sample Baselines](../../.github/workflows/test-samples-baselines.yml) workflow. +Locally: run the test, copy from `screenshot-out//screenshots/` into `tests/Stride.Samples.Tests//`. diff --git a/tests/Stride.Samples.Tests/SpaceEscape.cs b/tests/Stride.Samples.Tests/SpaceEscape.cs new file mode 100644 index 0000000000..e0ece5bc38 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpaceEscape.cs @@ -0,0 +1,29 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace SpaceEscape.Tests; + +[ScreenshotTest(TemplateId = "F9C4B79D-E313-47BC-9287-75A0395B8AC4")] +public class SpaceEscapeScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + // Capture quickly: a procedural obstacle in front of the ship can kill the run if we + // wait too long after starting. One lane change is enough to verify input is wired up. + const string LevelHint = "Endless runner with 3 lanes. Only check that the red ship is in the expected lane (center or left) — same as the baseline. Ignore everything else (procedurally-generated track segments, obstacle/pipe placement, distance counter, animation phase)."; + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("intro"); + await ctx.Tap(new Vector2(0.496875f, 0.8010563f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.Screenshot("center", claudeFallback: LevelHint); + await ctx.PressKey(Keys.Left, TimeSpan.FromMilliseconds(100)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.Screenshot("left", claudeFallback: LevelHint); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/SpaceEscape/center.png b/tests/Stride.Samples.Tests/SpaceEscape/center.png new file mode 100644 index 0000000000..7cc1701b31 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpaceEscape/center.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:946fa8e9a11074a37818855487544a4f284080d13bd0eb6696554e3eb503c0ca +size 872240 diff --git a/tests/Stride.Samples.Tests/SpaceEscape/intro.png b/tests/Stride.Samples.Tests/SpaceEscape/intro.png new file mode 100644 index 0000000000..4690d6509a --- /dev/null +++ b/tests/Stride.Samples.Tests/SpaceEscape/intro.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca35eeed0457807310dc5ff982a2088dd00fa5100df73777e6caa3d6c87baf52 +size 669060 diff --git a/tests/Stride.Samples.Tests/SpaceEscape/left.png b/tests/Stride.Samples.Tests/SpaceEscape/left.png new file mode 100644 index 0000000000..38218598ce --- /dev/null +++ b/tests/Stride.Samples.Tests/SpaceEscape/left.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae701f7f3f9bcf3c432c03c90ea94121d5e26590b649fe458d68b02f54b325b8 +size 895451 diff --git a/tests/Stride.Samples.Tests/SpriteFonts.cs b/tests/Stride.Samples.Tests/SpriteFonts.cs new file mode 100644 index 0000000000..baf4d14cf7 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts.cs @@ -0,0 +1,31 @@ +// 8 categories: Introduction / Static / Dynamic / Style / Alias / Language / Alignment / Animation. +// Pause the auto-advance with Space, then step through each with Right. +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace SpriteFonts.Tests; + +[ScreenshotTest(TemplateId = "1EEB50EC-1AA7-4D1F-9DDD-E5E12404B001")] +public class SpriteFontsScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.PressKey(Keys.Space, TimeSpan.FromMilliseconds(100)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.Screenshot("font-1"); + + for (int i = 2; i <= 8; i++) + { + await ctx.PressKey(Keys.Right, TimeSpan.FromMilliseconds(100)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot($"font-{i}"); + } + + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-1.png b/tests/Stride.Samples.Tests/SpriteFonts/font-1.png new file mode 100644 index 0000000000..c23e62baa4 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58e83f51a09c635b41e21d51f7ac0b5afedc65d197f2ca04a39138b1269cff8f +size 118389 diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-2.png b/tests/Stride.Samples.Tests/SpriteFonts/font-2.png new file mode 100644 index 0000000000..ee60a86926 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45008c782539140a70d0a0bbbe7888a0bf201ab6cda2f85efd2f2faab4244a3a +size 165701 diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-3.png b/tests/Stride.Samples.Tests/SpriteFonts/font-3.png new file mode 100644 index 0000000000..a9c36fc49c --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52a850af3692d5ba9a8e29ed4a382a19b36626067d95205f73936b900d3e4259 +size 172824 diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-4.png b/tests/Stride.Samples.Tests/SpriteFonts/font-4.png new file mode 100644 index 0000000000..f98498a0dd --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe54a065af50d50615fd285e4f1c968bfa893aa8f7b52435000cb226d440bf92 +size 138093 diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-5.png b/tests/Stride.Samples.Tests/SpriteFonts/font-5.png new file mode 100644 index 0000000000..51fde0d38e --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d8ef6280e83e404e86e481e3de2c150d963a66fbe9978ad67b428737779b9dc +size 145226 diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-6.png b/tests/Stride.Samples.Tests/SpriteFonts/font-6.png new file mode 100644 index 0000000000..50dd4617f8 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fd748b72689cac00edbe4f4f5f9e08b98f1c468208d1dc72c6346ce305f225a +size 150209 diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-7.png b/tests/Stride.Samples.Tests/SpriteFonts/font-7.png new file mode 100644 index 0000000000..32a9202613 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-7.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56c573f0806c0cf2f65d07f5c0bfb50cdc21269596df488727a044741c329df4 +size 277470 diff --git a/tests/Stride.Samples.Tests/SpriteFonts/font-8.png b/tests/Stride.Samples.Tests/SpriteFonts/font-8.png new file mode 100644 index 0000000000..a2aa3e4cbc --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteFonts/font-8.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2636194de7d968bb57f99446872334d05e535ff942e3efc352b15eab6fef7879 +size 118275 diff --git a/tests/Stride.Samples.Tests/SpriteStudioDemo.cs b/tests/Stride.Samples.Tests/SpriteStudioDemo.cs new file mode 100644 index 0000000000..c633f38544 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteStudioDemo.cs @@ -0,0 +1,31 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace SpriteStudioDemo.Tests; + +[ScreenshotTest(TemplateId = "6BE30E8D-9346-4130-87BE-12BF9CC362DE")] +public class SpriteStudioDemoScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + + const string SpriteHint = "Same forest scene, same girl character with sword. Chick (yellow blob enemy) spawn timing is non-deterministic — ignore differences in chick count or position between baseline and capture. Exact animation pose / sword swing phase can also vary."; + await ctx.Tap(new Vector2(0.83f, 0.05f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("after-tap", claudeFallback: SpriteHint); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + + await ctx.PressKey(Keys.Space, TimeSpan.FromMilliseconds(200)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(100)); + await ctx.Screenshot("after-space", claudeFallback: SpriteHint); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/SpriteStudioDemo/after-space.png b/tests/Stride.Samples.Tests/SpriteStudioDemo/after-space.png new file mode 100644 index 0000000000..94a4ccd370 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteStudioDemo/after-space.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f87e1c280639c09765eb3082a1bf2678c56ec079f3b3f56d846134b54b6d99e5 +size 357858 diff --git a/tests/Stride.Samples.Tests/SpriteStudioDemo/after-tap.png b/tests/Stride.Samples.Tests/SpriteStudioDemo/after-tap.png new file mode 100644 index 0000000000..ad6659c626 --- /dev/null +++ b/tests/Stride.Samples.Tests/SpriteStudioDemo/after-tap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:999610af81f44a41507801377afe18829f3d0219bd74519dbff173eb46440633 +size 363995 diff --git a/tests/Stride.Samples.Tests/ThirdPersonPlatformer.cs b/tests/Stride.Samples.Tests/ThirdPersonPlatformer.cs new file mode 100644 index 0000000000..0dc71a0697 --- /dev/null +++ b/tests/Stride.Samples.Tests/ThirdPersonPlatformer.cs @@ -0,0 +1,26 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace ThirdPersonPlatformer.Tests; + +[ScreenshotTest(TemplateId = "990311E4-152B-458D-8CBD-180903845DA7")] +public class ThirdPersonPlatformerScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + const string PoseHint = "Same arena, same character roughly in the middle of the floor. Idle animation phase varies (standing / crouching / squatting are all OK)."; + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.PressKey(Keys.Space, TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.Screenshot("after-jump", claudeFallback: PoseHint); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/ThirdPersonPlatformer/after-jump.png b/tests/Stride.Samples.Tests/ThirdPersonPlatformer/after-jump.png new file mode 100644 index 0000000000..05e23ed4df --- /dev/null +++ b/tests/Stride.Samples.Tests/ThirdPersonPlatformer/after-jump.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74277cf074cc76ae87bb91759bcf843ef19e650155671cddbff75c08882405c6 +size 841206 diff --git a/tests/Stride.Samples.Tests/TopDownRPG.cs b/tests/Stride.Samples.Tests/TopDownRPG.cs new file mode 100644 index 0000000000..3945818e3f --- /dev/null +++ b/tests/Stride.Samples.Tests/TopDownRPG.cs @@ -0,0 +1,25 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; +using Stride.Input; + +namespace TopDownRPG.Tests; + +[ScreenshotTest(TemplateId = "A363FBC5-89EF-4E7A-B870-6D070813D034")] +public class TopDownRPGScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Tap(new Vector2(0.5f, 0.7f), TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.PressKey(Keys.Space, TimeSpan.FromMilliseconds(500)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + await ctx.Screenshot("after-jump"); + await ctx.WaitTime(TimeSpan.FromMilliseconds(500)); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/TopDownRPG/after-jump.png b/tests/Stride.Samples.Tests/TopDownRPG/after-jump.png new file mode 100644 index 0000000000..415aced974 --- /dev/null +++ b/tests/Stride.Samples.Tests/TopDownRPG/after-jump.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e843e0d08edb73353fd022a2f9975e1280944e42e2e8e52aa909ae0f7a260daa +size 798940 diff --git a/tests/Stride.Samples.Tests/UIParticles.cs b/tests/Stride.Samples.Tests/UIParticles.cs new file mode 100644 index 0000000000..19d88ce3be --- /dev/null +++ b/tests/Stride.Samples.Tests/UIParticles.cs @@ -0,0 +1,33 @@ +#if STRIDE_AUTOTESTING +using System; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games.AutoTesting; + +namespace UIParticles.Tests; + +[ScreenshotTest(TemplateId = "DA4B1982-2A93-48FB-8EDA-7B13AD79E6A2")] +public class UIParticlesScreenshots : IScreenshotTest +{ + public async Task Run(IScreenshotTestContext ctx) + { + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + await ctx.Screenshot("initial"); + + await ctx.Tap(new Vector2(179f / 600f, 235f / 600f), TimeSpan.FromMilliseconds(150)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(250)); + await ctx.Screenshot("after-tap-1"); + + await ctx.Tap(new Vector2(360f / 600f, 328f / 600f), TimeSpan.FromMilliseconds(150)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(1250)); + await ctx.Screenshot("after-tap-2"); + + await ctx.Tap(new Vector2(179f / 600f, 235f / 600f), TimeSpan.FromMilliseconds(150)); + await ctx.WaitTime(TimeSpan.FromMilliseconds(250)); + await ctx.Screenshot("after-tap-3"); + + await ctx.WaitTime(TimeSpan.FromMilliseconds(2000)); + ctx.Exit(); + } +} +#endif diff --git a/tests/Stride.Samples.Tests/UIParticles/after-tap-1.png b/tests/Stride.Samples.Tests/UIParticles/after-tap-1.png new file mode 100644 index 0000000000..2477b38286 --- /dev/null +++ b/tests/Stride.Samples.Tests/UIParticles/after-tap-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e013787fc08ee3954d69ecccbda930545b316e532febacb16625fec5b80c676 +size 303090 diff --git a/tests/Stride.Samples.Tests/UIParticles/after-tap-2.png b/tests/Stride.Samples.Tests/UIParticles/after-tap-2.png new file mode 100644 index 0000000000..cf31009641 --- /dev/null +++ b/tests/Stride.Samples.Tests/UIParticles/after-tap-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a6d98a27d75e957c4ace590713e758f2882e79e870d0be0af293eb18dee6132 +size 291298 diff --git a/tests/Stride.Samples.Tests/UIParticles/after-tap-3.png b/tests/Stride.Samples.Tests/UIParticles/after-tap-3.png new file mode 100644 index 0000000000..edb8c92e22 --- /dev/null +++ b/tests/Stride.Samples.Tests/UIParticles/after-tap-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3415a43f5df5ee792301d71a47e296144328662e9c038b0bd7da2621059e6a33 +size 300512 diff --git a/tests/Stride.Samples.Tests/UIParticles/initial.png b/tests/Stride.Samples.Tests/UIParticles/initial.png new file mode 100644 index 0000000000..ff4d910673 --- /dev/null +++ b/tests/Stride.Samples.Tests/UIParticles/initial.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9da845028342406e869065a315defba1bc8368ceafe650c5a79ad6a198e696a9 +size 301759