acdream/tools/run-offline-pixel-gate.ps1
Erik 30e72a2af8 research(vm0): Campaign AR's pack-off path IS the pre-campaign renderer - PASS
Exact-pixel and production-perf comparison of 6c79d35c (+ only the A2
normal files, so no terrain mask is needed) against c51b07ef with no pack:
connected as +Acdream, visible window, one isolated config clone per
variant, pinned clocks. Open field: the only differences are idle pose,
mana digits and a passing flyer. Holtburg: same-binary-twice defines the
dynamic mask (9.8%); in the static 90% both self-diffs have ZERO pixels
with |d|>=8 while base+normals vs HEAD-off has 841/729 - all streaks inside
the animated lifestone. Buildings, ground, trees, sky and UI are clean.

Perf (uncapped Release, no automation observer, ACDREAM_FRAME_PROF=1):
Holtburg CPU p50 4.7 -> 4.1 ms, Arwic 6.0 -> 5.2 ms, GPU unchanged,
alloc/frame 574 KB -> 21 KB. No regression; F5b's '27.8 ms retail CPU' was
the observer.

Three false alarms recorded so nobody repeats them: the isolated gate
settings lack fieldOfView (90 vs the real 86.33 -> a 0.952 zoom); the real
%APPDATA% settings still selected acdream.atmospheric/low (pack ON); a
minimized GLFW window is throttled and never settles.

Tools: tools/vm0/capture-visible.ps1 (pre-campaign gate + -Exe/-Live/
-ConfigDir/-CharacterName/-PreCaptureCommand), tools/vm0/perf-run.sh,
and -BuildingDetailTextures on run-offline-pixel-gate.ps1. Baseline
patches under docs/research/evidence/vm0/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:40:40 +02:00

530 lines
24 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 OrbitDistanceMeters
Diagnostic initial orbit-camera distance in metres. Zero keeps acdream's
normal default.
.PARAMETER OrbitYawDegrees
Diagnostic initial orbit-camera heading in degrees. Omit to keep acdream's
normal default.
.PARAMETER OrbitPitchDegrees
Diagnostic initial orbit-camera elevation in degrees. Omit to keep
acdream's normal default. A shallow positive angle can put a low sun in
frame for ray and volumetric-shaft captures.
.PARAMETER BuildingDetailTextures
Retail Building Detail Textures preference written into the isolated
settings (#226). Default $true (the product default). $false captures the
pack-off frame without the building/EnvCell detail replay, which is how
Campaign VM slice VM0 isolates that parity change from the rest.
.PARAMETER Uncapped
Disable both VSync and the normal refresh-rate software limiter. Omit for
the capped product cadence. This is a diagnostic measurement mode only.
.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,
[ValidateSet('retail', 'low', 'medium', 'high', 'auto')]
[string]$RenderPackPreset = 'retail',
[ValidatePattern('^[1-9][0-9]*x[1-9][0-9]*$')]
[string]$Resolution = '1280x720',
[ValidateRange(0.0, 5000.0)]
[double]$OrbitDistanceMeters = 0,
[Nullable[double]]$OrbitYawDegrees,
[ValidateRange(-89.0, 89.0)]
[Nullable[double]]$OrbitPitchDegrees,
[hashtable]$RenderPackSettingOverrides = @{},
[ValidateRange(0, 2048)]
[int]$RequiredRenderPackSamples = 0,
[ValidateRange(1000, 600000)]
[int]$RenderPackSampleTimeoutMs = 300000,
[switch]$AllowSafeRenderPackFallback,
[switch]$Uncapped,
# Campaign VM slice VM0: pin the retail Building Detail Textures preference
# (#226). Default keeps the product default (on). Pass $false to capture the
# pack-off frame without the building/EnvCell detail replay.
[bool]$BuildingDetailTextures = $true,
[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'
. (Join-Path $PSScriptRoot 'atmospheric-performance-matrix-common.ps1')
if ($AllowSafeRenderPackFallback -and $RenderPackPreset -eq 'retail') {
throw '-AllowSafeRenderPackFallback is valid only for an explicitly selected enhanced preset.'
}
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
# Every capture owns a complete disposable path set. This prevents an offline
# gate from inheriting or rewriting the user's real pack selection, settings,
# plugins, screenshots, or pipeline cache.
$state = Join-Path $Out 'isolated-state'
$config = Join-Path $state 'config'
$data = Join-Path $state 'data'
$cache = Join-Path $state 'cache'
New-Item -ItemType Directory -Force -Path $config, $data, $cache | Out-Null
$packId = if ($RenderPackPreset -eq 'retail') { 'retail' } else { 'acdream.atmospheric' }
$packVersion = if ($RenderPackPreset -eq 'retail') { $null } else { '1.0.0' }
$presetId = if ($RenderPackPreset -eq 'retail') { 'off' } else { $RenderPackPreset }
$orderedOverrides = [ordered]@{}
foreach ($key in @($RenderPackSettingOverrides.Keys | Sort-Object)) {
$orderedOverrides[$key] = [string]$RenderPackSettingOverrides[$key]
}
$settings = [ordered]@{
display = [ordered]@{
resolution = $Resolution
fullscreen = $false
vsync = $false
buildingDetailTextures = $BuildingDetailTextures
renderPack = [ordered]@{
packId = $packId
packVersion = $packVersion
presetId = $presetId
settingOverrides = $orderedOverrides
}
}
version = 3
}
$settings | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 `
-LiteralPath (Join-Path $config 'settings.json')
$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.
$probeCommands = [Collections.Generic.List[string]]::new()
$probeCommands.Add("sleep $WarmupMs")
if ($RequiredRenderPackSamples -gt 0) {
# Explicit presets discard startup evidence after warmup. Auto deliberately
# owns a continuous rolling window for its hysteresis policy and rejects a
# diagnostic reset; after the same warmup we wait until its current stable
# resource generation contains one complete window.
if ($RenderPackPreset -ne 'auto') {
$probeCommands.Add('renderpack reset-performance')
}
$probeCommands.Add(
"wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs")
if ($AllowSafeRenderPackFallback) {
# The screenshot controller reads the last completed swapchain image.
# A runtime Auto fallback can publish retail at the boundary that
# satisfies the wait while that completed image still belongs to the
# prior enhanced frame. Give the default path time to present fresh
# frames before pairing its pixels with the retail oracle.
$probeCommands.Add('sleep 2000')
}
}
$probeCommands.Add('screenshot world-offline 30000')
# Keep the process alive briefly after the PNG commit so this parent can record
# the matching process envelope, then ask the hidden client itself to execute
# the ordinary IWindow.Close shutdown path. Hidden windows intentionally have
# no MainWindowHandle, so WM_CLOSE cannot be the primary close mechanism.
$probeCommands.Add('sleep 4000')
$probeCommands.Add('close-client')
Set-Content -Encoding utf8 -Path $probe -Value $probeCommands
$log = Join-Path $Out 'client.log'
# --- 3. Launch offline --------------------------------------------------------
$previousLive = $env:ACDREAM_LIVE
$previousConfigDirectory = $env:ACDREAM_CONFIG_DIR
$previousDataDirectory = $env:ACDREAM_DATA_DIR
$previousCacheDirectory = $env:ACDREAM_CACHE_DIR
$previousOrbitDistance = $env:ACDREAM_ORBIT_DISTANCE_METERS
$previousOrbitYaw = $env:ACDREAM_ORBIT_YAW_DEGREES
$previousOrbitPitch = $env:ACDREAM_ORBIT_PITCH_DEGREES
$previousUncappedRender = $env:ACDREAM_UNCAPPED_RENDER
$previousExactFramebuffer = $env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER
Remove-Item Env:\ACDREAM_LIVE -ErrorAction SilentlyContinue
$env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call"
$env:ACDREAM_CONFIG_DIR = $config
$env:ACDREAM_DATA_DIR = $data
$env:ACDREAM_CACHE_DIR = $cache
$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
$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = '1'
$env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null }
if ($OrbitDistanceMeters -gt 0) {
$env:ACDREAM_ORBIT_DISTANCE_METERS = $OrbitDistanceMeters.ToString(
[System.Globalization.CultureInfo]::InvariantCulture)
} else {
Remove-Item Env:\ACDREAM_ORBIT_DISTANCE_METERS -ErrorAction SilentlyContinue
}
if ($null -ne $OrbitYawDegrees) {
$env:ACDREAM_ORBIT_YAW_DEGREES = ([double]$OrbitYawDegrees).ToString(
[System.Globalization.CultureInfo]::InvariantCulture)
} else {
Remove-Item Env:\ACDREAM_ORBIT_YAW_DEGREES -ErrorAction SilentlyContinue
}
if ($null -ne $OrbitPitchDegrees) {
$env:ACDREAM_ORBIT_PITCH_DEGREES = ([double]$OrbitPitchDegrees).ToString(
[System.Globalization.CultureInfo]::InvariantCulture)
} else {
Remove-Item Env:\ACDREAM_ORBIT_PITCH_DEGREES -ErrorAction SilentlyContinue
}
# 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 (pack $packId/$presetId, $Resolution, $(if ($Uncapped) { 'uncapped' } else { 'capped' }), orbit ${OrbitDistanceMeters}m/$OrbitYawDegrees deg yaw/$OrbitPitchDegrees deg pitch, 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 Hidden
try {
$shots = Join-Path $Out 'screenshots'
$sampleWaitMs = if ($RequiredRenderPackSamples -gt 0) {
$RenderPackSampleTimeoutMs
} else { 0 }
$deadline = (Get-Date).AddMilliseconds(
$WarmupMs + $sampleWaitMs + 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
$proc.Refresh()
[pscustomobject][ordered]@{
SchemaVersion = 1
CapturedUtc = [DateTime]::UtcNow.ToString('O')
WorkingSetBytes = [long]$proc.WorkingSet64
PrivateMemoryBytes = [long]$proc.PrivateMemorySize64
HandleCount = [int]$proc.HandleCount
ThreadCount = [int]$proc.Threads.Count
} | ConvertTo-Json -Depth 3 | Set-Content -Encoding utf8 `
-LiteralPath (Join-Path $Out 'capture-process.json')
}
finally {
# The probe's close-client verb runs acdream's normal IWindow.Close path.
# Wait for that exact process; never target another AcDream.App instance.
# Force is cleanup-only and makes the gate fail rather than disguising a
# broken ownership/shutdown path as a successful capture.
$shutdownFailure = $null
$proc.Refresh()
if (-not $proc.HasExited -and -not $proc.WaitForExit(15000)) {
$shutdownFailure = 'in-process automation close timed out'
Write-Step "$shutdownFailure; forcing exact capture process"
Stop-Process -Id $proc.Id -Force
$proc.WaitForExit()
}
if ($null -eq $shutdownFailure -and $proc.ExitCode -ne 0) {
$shutdownFailure = "client exited with code $($proc.ExitCode)"
}
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 ($null -eq $previousOrbitDistance) {
Remove-Item Env:\ACDREAM_ORBIT_DISTANCE_METERS -ErrorAction SilentlyContinue
} else { $env:ACDREAM_ORBIT_DISTANCE_METERS = $previousOrbitDistance }
if ($null -eq $previousOrbitYaw) {
Remove-Item Env:\ACDREAM_ORBIT_YAW_DEGREES -ErrorAction SilentlyContinue
} else { $env:ACDREAM_ORBIT_YAW_DEGREES = $previousOrbitYaw }
if ($null -eq $previousOrbitPitch) {
Remove-Item Env:\ACDREAM_ORBIT_PITCH_DEGREES -ErrorAction SilentlyContinue
} else { $env:ACDREAM_ORBIT_PITCH_DEGREES = $previousOrbitPitch }
if ($null -eq $previousUncappedRender) {
Remove-Item Env:\ACDREAM_UNCAPPED_RENDER -ErrorAction SilentlyContinue
} else { $env:ACDREAM_UNCAPPED_RENDER = $previousUncappedRender }
if ($null -eq $previousExactFramebuffer) {
Remove-Item Env:\ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER -ErrorAction SilentlyContinue
} else {
$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = $previousExactFramebuffer
}
if ($previousLive) { $env:ACDREAM_LIVE = $previousLive }
if ($null -eq $previousConfigDirectory) {
Remove-Item Env:\ACDREAM_CONFIG_DIR -ErrorAction SilentlyContinue
} else { $env:ACDREAM_CONFIG_DIR = $previousConfigDirectory }
if ($null -eq $previousDataDirectory) {
Remove-Item Env:\ACDREAM_DATA_DIR -ErrorAction SilentlyContinue
} else { $env:ACDREAM_DATA_DIR = $previousDataDirectory }
if ($null -eq $previousCacheDirectory) {
Remove-Item Env:\ACDREAM_CACHE_DIR -ErrorAction SilentlyContinue
} else { $env:ACDREAM_CACHE_DIR = $previousCacheDirectory }
if ($null -ne $shutdownFailure) {
throw $shutdownFailure
}
}
$captures = @(Get-ChildItem (Join-Path $Out 'screenshots') -Filter *.png)
Write-Step "captured $($captures.Count) screenshot(s) into $Out"
$metadataPath = Join-Path $Out 'screenshots\world-offline.metadata.json'
if (-not (Test-Path -LiteralPath $metadataPath)) {
throw "Render-pack screenshot metadata is missing at '$metadataPath'."
}
$metadata = Get-Content -Raw -LiteralPath $metadataPath | ConvertFrom-Json
if ($AllowSafeRenderPackFallback) {
$dimensions = $Resolution.Split('x')
$evidence = Test-AtmosphericPerformanceMetadataEvidence `
-MetadataPath $metadataPath `
-Preset $RenderPackPreset `
-ExpectedWidth ([int]$dimensions[0]) `
-ExpectedHeight ([int]$dimensions[1]) `
-AllowSafeFallback
if (-not $evidence.Passed) {
throw ('Capture is neither an active complete evidence window nor a safe ' +
'resource/capability fallback: ' + (@($evidence.Failures) -join '; '))
}
Write-Step ("render-pack capture outcome: {0}{1}" -f `
$evidence.Outcome,
$(if ($evidence.Outcome -eq 'Unavailable') {
" ($($evidence.UnavailableClassification): $($evidence.FailureReason))"
} else { '' }))
}
else {
if (($metadata.RenderPack.PackId -ne $packId) -or
($metadata.RenderPack.PresetId -ne $presetId)) {
throw ("Capture selected {0}/{1}, expected {2}/{3}. Failure: {4}" -f `
$metadata.RenderPack.PackId,
$metadata.RenderPack.PresetId,
$packId,
$presetId,
$metadata.RenderPack.FailureReason)
}
$expectedState = if ($RenderPackPreset -eq 'retail') { 0 } else { 2 }
if ([int]$metadata.RenderPack.State -ne $expectedState) {
throw ("Capture render-pack state was {0}, expected {1}. Failure: {2}" -f `
$metadata.RenderPack.State,
$expectedState,
$metadata.RenderPack.FailureReason)
}
}
# 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