Closes out the GL deletion by fixing the CI workflow and developer gate
scripts that still assumed a GL arm existed to compare against, build,
or select via ACDREAM_RENDER_BACKEND.
.github/workflows/headless-portability.yml: linux-graphical's "Verify
actionable unsupported-driver gate" step is deleted outright — it ran
the deleted `ui-studio` CLI verb (Studio was removed at Commit 1) to
prove the GL capability gate rejects Mesa's llvmpipe driver, and there
is no more GL capability gate for any driver to pass or fail. Its test
filter dropped two dead entries (GraphicalCapabilityRequirementsTests,
deleted at Commit 2; StudioWindowTests, already gone). Its package
contract check dropped the libcimgui.so assertion (ImGui's native
bridge, deleted at Commit 1). linux-vulkan's explanatory comment, which
described GL's rejection as the reason no cross-backend pixel diff runs
in CI, is rewritten to explain there is no GL arm left at all. Two dead
src/AcDream.UI.ImGui/** path triggers (that project no longer exists)
are removed from both the pull_request and push filters.
tools/run-backend-differential-gate.ps1 and its dedicated route file
tools/connected-backend-differential.route.txt are deleted: the whole
script's purpose was comparing a GL launch against a Vulkan launch of
the same route, and there is no second arm left to compare. Single-arm
regression checking already exists via run-offline-pixel-gate.ps1's
-Baseline mechanism.
tools/run-portal-churn-soak.ps1 is simplified rather than deleted: its
repeated-portal-churn methodology (within-arm capture comparison,
memory/entity/GPU trend analysis) has value independent of the
GL-versus-Vulkan question it was built to answer for issues #256/#257
before V11. -Backends now defaults to @('vulkan') alone; the doc
comments are rewritten from "step 0 discriminator, run before V11" to
an ongoing single-arm regression soak.
tools/run-offline-pixel-gate.ps1 drops its now-nonfunctional -Backend
parameter (ACDREAM_RENDER_BACKEND has read zero call sites since
RuntimeOptions.RenderBackend was removed at Commit 2 — passing -Backend
gl silently launched Vulkan anyway) along with its GL-escape-hatch
example and every comment that referenced the now-deleted differential
gate. tools/run-connected-world-lifecycle-gate.ps1,
tools/run-offline-vulkan-capture.ps1, and
tools/run-repeat-connected-gate.ps1 keep their (harmless, already
no-op) ACDREAM_RENDER_BACKEND set/clear lines but have their
now-inaccurate "escape hatch" / "GL run" comments corrected to state
plainly that the variable is unread and the line is kept only for the
historical record.
No .cs files touched; `dotnet build AcDream.slnx -c Release` unaffected
(0 warnings, 0 errors, matching the prior commit's build).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
307 lines
13 KiB
PowerShell
307 lines
13 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Campaign V self-differential pixel gate. Captures deterministic offline
|
|
screenshots and optionally compares them against a baseline capture.
|
|
|
|
.DESCRIPTION
|
|
Every Campaign V renderer-port slice must prove it changed no pixels. The
|
|
connected lifecycle gate needs a live ACE server and the user's attention;
|
|
this gate needs neither. The client is launched WITHOUT ACDREAM_LIVE, so it
|
|
renders the world straight from the DATs, no session is created, and no ACE
|
|
state can be disturbed.
|
|
|
|
Coverage: terrain, terrain blending, scenery, static world meshes, sky and
|
|
fog, and the full retained UI (vitals, spell bar, toolbar, chat, radar).
|
|
It does NOT cover EnvCell interiors, particles, or the paperdoll viewport —
|
|
those still need the connected route, so slices touching them (V4e, V4g)
|
|
must additionally be gated by the user.
|
|
|
|
Determinism levers, all three forced here rather than left to the ambient
|
|
environment, because a capture that inherits any of them is not comparable
|
|
against one that did not:
|
|
|
|
* ACDREAM_DAY_GROUP -- the sky day group, so weather and the keyframe
|
|
do not drift between runs.
|
|
* ACDREAM_WORLD_TIME -- the Dereth day fraction. This moves the SUN, so
|
|
it moves every lit surface, not just the sky. Slice V7 measured 22.3% of
|
|
the frame differing between two captures 45 s apart at one stop on one
|
|
backend with this unpinned.
|
|
* ACDREAM_SKY_PHASE_SECONDS -- the cloud sheet's UV scroll, which runs on
|
|
wall time and is independent of the world clock by design.
|
|
|
|
ACDREAM_MSAA_SAMPLES is forced to 0 because multisample resolve positions
|
|
are explicitly unspecified across implementations, and at 4x they swamp
|
|
the threshold ninety-fold with two drivers' sample patterns. See plan
|
|
section 5.5.16.
|
|
|
|
A fixed warm-up lets streaming settle before the frame is captured.
|
|
|
|
.PARAMETER Out
|
|
Directory to write this capture into. Created if absent, emptied if present.
|
|
|
|
.PARAMETER Baseline
|
|
Optional. A directory from a previous capture. When supplied, every PNG is
|
|
compared against its namesake and the script fails on any mismatch.
|
|
|
|
.PARAMETER WarmupMs
|
|
Milliseconds to let the world stream and settle before capturing. Default
|
|
12000, which reliably reaches a fully populated view on the reference machine.
|
|
|
|
.PARAMETER DayGroup
|
|
Sky day-group index to pin. Default 0.
|
|
|
|
.PARAMETER Tolerance
|
|
Per-channel absolute difference allowed. Default 2, matching the project's
|
|
pinned screenshot rule.
|
|
|
|
.PARAMETER MaxDifferentFraction
|
|
Fraction of differing pixels allowed. Default 0.001, likewise pinned.
|
|
|
|
.PARAMETER WorldDayFraction
|
|
Dereth day fraction pinned on the launch, in [0, 1). Default 0.5 -- noon, the
|
|
brightest and flattest lighting the day has.
|
|
|
|
.PARAMETER SkyPhaseSeconds
|
|
Elapsed-seconds value the sky's UV scroll is pinned to. Default 0, the cloud
|
|
sheet's authored origin.
|
|
|
|
.PARAMETER MsaaSamples
|
|
MSAA sample count forced on the launch. Default 0 -- off, which is what plan
|
|
section 2's pixel-acceptance contract specifies. Pass -1 to leave the quality
|
|
preset alone.
|
|
|
|
.PARAMETER MaskTopPixels
|
|
Height in pixels of the sky and treeline band excluded from comparison.
|
|
Default 280.
|
|
|
|
This is not a fudge factor, and slice V10 re-measured it rather than assuming
|
|
the V7 clock pins had retired it. Two captures at the SAME commit once
|
|
differed in 0.29% of pixels, essentially all of it in the top rows, because
|
|
the sky legitimately animates: clouds scroll on wall time and the Dereth
|
|
clock moves the sun, and therefore every lit surface. Pinning both clocks --
|
|
which this script now does -- shrinks that but does NOT remove it. Measured
|
|
at V10, two launches of the same binary with all four levers pinned:
|
|
|
|
whole frame, GL vs GL 1,011 px of 921,600 (1.10e-03)
|
|
whole frame, VK vs VK 482 px of 921,600 (5.23e-04)
|
|
top 280 masked, GL vs GL 10 px of 563,200 (1.78e-05)
|
|
top 280 masked, VK vs VK 8 px of 563,200 (1.42e-05)
|
|
|
|
So the band still moves between launches on both backends -- enough that an
|
|
unmasked self-differential on GL fails the 0.001 threshold on noise alone --
|
|
and everything below it is bit-stable to about ten pixels. Masking the band
|
|
is what makes the rest a strict identity check instead of forcing a loose
|
|
tolerance that would hide real regressions everywhere else.
|
|
|
|
Consequence, unchanged: this gate does NOT cover sky rendering.
|
|
|
|
Do NOT reach for a LARGER value to make a failing gate pass: plan section 7.1
|
|
rule 2. Its legitimate use is diagnostic -- reporting a masked/unmasked split
|
|
so a known, registered band (for instance AD-46's treeline) can be quantified
|
|
separately from the rest of the frame.
|
|
|
|
.PARAMETER SkipBuild
|
|
Skip the Release build (use when the caller already built).
|
|
|
|
.EXAMPLE
|
|
# Capture a baseline at the parent commit, then gate the slice:
|
|
git stash
|
|
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-base
|
|
git stash pop
|
|
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-head -Baseline artifacts/gate-base
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Out,
|
|
[string]$Baseline,
|
|
[int]$WarmupMs = 12000,
|
|
[int]$DayGroup = 0,
|
|
[double]$WorldDayFraction = 0.5,
|
|
[double]$SkyPhaseSeconds = 0,
|
|
[int]$MsaaSamples = 0,
|
|
[int]$Tolerance = 2,
|
|
[double]$MaxDifferentFraction = 0.001,
|
|
[int]$MaskTopPixels = 280,
|
|
[switch]$SkipBuild
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$repo = Split-Path -Parent $PSScriptRoot
|
|
$exe = Join-Path $repo 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe'
|
|
$cli = Join-Path $repo 'src\AcDream.Cli\bin\Release\net10.0\AcDream.Cli.dll'
|
|
|
|
function Write-Step($message) { Write-Host "[pixel-gate] $message" }
|
|
|
|
# --- 1. Build -----------------------------------------------------------------
|
|
if (-not $SkipBuild) {
|
|
Write-Step 'building Release'
|
|
& dotnet build (Join-Path $repo 'AcDream.slnx') -c Release --nologo -v q | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE." }
|
|
}
|
|
if (-not (Test-Path $exe)) { throw "Client not found at $exe. Build Release first." }
|
|
|
|
# --- 2. Prepare the capture directory and probe script ------------------------
|
|
if (Test-Path $Out) { Remove-Item -Recurse -Force $Out }
|
|
New-Item -ItemType Directory -Force -Path $Out | Out-Null
|
|
|
|
$probe = Join-Path $Out 'offline.probe.txt'
|
|
# The script runner reads one command per line. A single settled capture is the
|
|
# whole gate: a second stop would need camera movement, which offline has no
|
|
# deterministic way to drive.
|
|
Set-Content -Encoding utf8 -Path $probe -Value @"
|
|
sleep $WarmupMs
|
|
screenshot world-offline 30000
|
|
sleep 500
|
|
"@
|
|
|
|
$log = Join-Path $Out 'client.log'
|
|
|
|
# --- 3. Launch offline --------------------------------------------------------
|
|
$previousLive = $env:ACDREAM_LIVE
|
|
Remove-Item Env:\ACDREAM_LIVE -ErrorAction SilentlyContinue
|
|
$env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call"
|
|
$env:ACDREAM_NO_AUDIO = '1'
|
|
$env:ACDREAM_RETAIL_UI = '1'
|
|
$env:ACDREAM_DAY_GROUP = "$DayGroup"
|
|
$env:ACDREAM_UI_PROBE_SCRIPT = $probe
|
|
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $Out
|
|
|
|
# The determinism pins, forced rather than inherited. See .DESCRIPTION.
|
|
$invariant = [System.Globalization.CultureInfo]::InvariantCulture
|
|
$env:ACDREAM_WORLD_TIME = $WorldDayFraction.ToString($invariant)
|
|
$env:ACDREAM_SKY_PHASE_SECONDS = $SkyPhaseSeconds.ToString($invariant)
|
|
if ($MsaaSamples -ge 0) { $env:ACDREAM_MSAA_SAMPLES = "$MsaaSamples" }
|
|
else { Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue }
|
|
|
|
Write-Step "launching offline client (warmup ${WarmupMs}ms, day group $DayGroup, day fraction $WorldDayFraction, sky phase $SkyPhaseSeconds, MSAA $MsaaSamples)"
|
|
$proc = Start-Process -FilePath $exe -RedirectStandardOutput $log `
|
|
-RedirectStandardError "$log.err" -PassThru -WindowStyle Minimized
|
|
|
|
try {
|
|
$shots = Join-Path $Out 'screenshots'
|
|
$deadline = (Get-Date).AddMilliseconds($WarmupMs + 60000)
|
|
$captured = $false
|
|
while ((Get-Date) -lt $deadline) {
|
|
if ((Test-Path $shots) -and (Get-ChildItem $shots -Filter *.png -ErrorAction SilentlyContinue)) {
|
|
$captured = $true
|
|
break
|
|
}
|
|
if ($proc.HasExited) { break }
|
|
Start-Sleep -Milliseconds 1000
|
|
}
|
|
|
|
if (-not $captured) {
|
|
Write-Host (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue)
|
|
throw 'No screenshot was captured before the deadline.'
|
|
}
|
|
# Let the probe script finish its trailing sleep so the PNG is fully flushed.
|
|
Start-Sleep -Milliseconds 1500
|
|
}
|
|
finally {
|
|
# Graceful close: WM_CLOSE runs the shutdown path, so the ownership ledger
|
|
# converges the way the lifecycle tests expect. No ACE session exists here,
|
|
# but keeping the habit means this script is safe to point at a live run too.
|
|
$app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
|
|
if ($app) {
|
|
$app.CloseMainWindow() | Out-Null
|
|
if (-not $app.WaitForExit(10000)) {
|
|
Write-Step 'WM_CLOSE timed out; forcing'
|
|
$app | Stop-Process -Force
|
|
}
|
|
}
|
|
Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue
|
|
Remove-Item Env:\ACDREAM_WORLD_TIME -ErrorAction SilentlyContinue
|
|
Remove-Item Env:\ACDREAM_SKY_PHASE_SECONDS -ErrorAction SilentlyContinue
|
|
if ($previousLive) { $env:ACDREAM_LIVE = $previousLive }
|
|
}
|
|
|
|
$captures = Get-ChildItem (Join-Path $Out 'screenshots') -Filter *.png
|
|
Write-Step "captured $($captures.Count) screenshot(s) into $Out"
|
|
|
|
# The offline window is minimised but still focusable, so a stray scroll or key
|
|
# press from whoever is at the keyboard can move the camera mid-capture. That
|
|
# produces two screenshots of the same scene from different camera positions —
|
|
# a huge, entirely spurious pixel difference. This actually happened during
|
|
# slice V4b. Detect it and fail loudly rather than let a comparison be
|
|
# interpreted as a rendering regression.
|
|
$cameraInput = Select-String -Path $log -Pattern 'ScrollUp|ScrollDown|ZoomIn|ZoomOut|CameraZoom' `
|
|
-CaseSensitive -ErrorAction SilentlyContinue
|
|
if ($cameraInput) {
|
|
Write-Host ''
|
|
Write-Host '[pixel-gate] ABORTED: camera-affecting input reached the capture window.' -ForegroundColor Red
|
|
Write-Host ' The capture is not comparable. Re-run without touching the machine.' -ForegroundColor Red
|
|
$cameraInput | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line)" -ForegroundColor Red }
|
|
exit 2
|
|
}
|
|
|
|
# --- 4. Compare against the baseline -----------------------------------------
|
|
if (-not $Baseline) {
|
|
Write-Step 'no baseline supplied; capture only'
|
|
exit 0
|
|
}
|
|
|
|
if (-not (Test-Path $cli)) { throw "AcDream.Cli not found at $cli." }
|
|
|
|
# Build the sky mask. The comparer skips any pixel whose mask alpha is non-zero,
|
|
# so the band we exclude is opaque and everything compared is transparent.
|
|
$maskPath = $null
|
|
if ($MaskTopPixels -gt 0) {
|
|
Add-Type -AssemblyName System.Drawing
|
|
$probeImage = [System.Drawing.Bitmap]::FromFile($captures[0].FullName)
|
|
$width = $probeImage.Width
|
|
$height = $probeImage.Height
|
|
$probeImage.Dispose()
|
|
|
|
$mask = New-Object System.Drawing.Bitmap($width, $height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
|
$graphics = [System.Drawing.Graphics]::FromImage($mask)
|
|
$graphics.Clear([System.Drawing.Color]::FromArgb(0, 0, 0, 0))
|
|
$opaque = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 0, 255))
|
|
$graphics.FillRectangle($opaque, 0, 0, $width, [Math]::Min($MaskTopPixels, $height))
|
|
$graphics.Dispose()
|
|
$opaque.Dispose()
|
|
|
|
$maskPath = Join-Path $Out 'sky-mask.png'
|
|
$mask.Save($maskPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
$mask.Dispose()
|
|
Write-Step "masking the top $MaskTopPixels rows (animated sky and treeline)"
|
|
}
|
|
|
|
$failed = @()
|
|
foreach ($shot in $captures) {
|
|
$expected = Join-Path $Baseline "screenshots\$($shot.Name)"
|
|
if (-not (Test-Path $expected)) {
|
|
$failed += "$($shot.Name): no baseline counterpart"
|
|
continue
|
|
}
|
|
|
|
$report = Join-Path $Out "compare-$($shot.BaseName).json"
|
|
if ($maskPath) {
|
|
& dotnet $cli compare-screenshots $expected $shot.FullName $report $Tolerance $MaxDifferentFraction $maskPath | Out-Null
|
|
}
|
|
else {
|
|
& dotnet $cli compare-screenshots $expected $shot.FullName $report $Tolerance $MaxDifferentFraction | Out-Null
|
|
}
|
|
$verdict = Get-Content $report -Raw | ConvertFrom-Json
|
|
|
|
$fraction = $verdict.differentPixelFraction
|
|
if ($null -eq $fraction) { $fraction = $verdict.DifferentPixelFraction }
|
|
$passed = $verdict.passed
|
|
if ($null -eq $passed) { $passed = $verdict.Passed }
|
|
|
|
if ($passed) {
|
|
Write-Step "PASS $($shot.Name) (differing fraction $fraction)"
|
|
}
|
|
else {
|
|
$failed += "$($shot.Name): differing fraction $fraction exceeds $MaxDifferentFraction (report: $report)"
|
|
}
|
|
}
|
|
|
|
if ($failed.Count -gt 0) {
|
|
Write-Host ''
|
|
Write-Host '[pixel-gate] FAILED:' -ForegroundColor Red
|
|
$failed | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
|
exit 1
|
|
}
|
|
|
|
Write-Step 'all screenshots match the baseline'
|
|
exit 0
|