-ExeOverride runs another AcDream.App.exe through the SAME isolated
settings/scene/capture path so two binaries compare without mixing tool
versions (VM0's recorded fieldOfView trap). -DebuggerScript launches the
client under cdb so ntdll's debug heap validates every free and a heap
corruption breaks at the corrupting site with a stack.
#422 (1-in-8 STATUS_HEAP_CORRUPTION exit after a pack-on offline capture):
16 runs attached + 24 runs under the debug heap on 1f151242, all clean
(P ~ 0.5 % at the filed rate). Recorded as not reproducible on the current
binary, carried as a watch item for the owner's VM7 matrix with the loop as
the first step if it recurs; no longer a merge blocker. Evidence under
docs/research/evidence/vm7 alongside the final pack-off-vs-base+normals
robust diff (5-10 px, lifestone band only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
565 lines
25 KiB
PowerShell
565 lines
25 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).
|
|
|
|
.PARAMETER ExeOverride
|
|
Run this AcDream.App.exe instead of the repository's Release build, with
|
|
the SAME isolated settings, scene and capture path. Campaign VM VM7 used
|
|
it to compare the final binary against a reference binary built in a
|
|
throwaway worktree (VM0's base+normals) without mixing tool versions —
|
|
VM0 recorded that captures from two different tool versions are not
|
|
comparable (isolated-settings fieldOfView). Implies -SkipBuild.
|
|
|
|
.PARAMETER DebuggerScript
|
|
Launch the client UNDER cdb (x64 Windows Debugger, -G -o -lines -cf
|
|
<script>) instead of directly. A process started under a debugger runs
|
|
with ntdll's debug heap (per-free validation), so a heap corruption
|
|
breaks at the corrupting site with a stack instead of fail-fasting at
|
|
exit. Campaign VM VM7 used it to characterise #422. The debugger's
|
|
console output (which includes the client's stdout) goes to client.log.
|
|
|
|
.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,
|
|
[string]$ExeOverride,
|
|
[string]$DebuggerScript
|
|
)
|
|
|
|
$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 ($ExeOverride) {
|
|
if (-not (Test-Path $ExeOverride)) { throw "ExeOverride not found: $ExeOverride" }
|
|
$exe = (Resolve-Path $ExeOverride).Path
|
|
$SkipBuild = $true
|
|
}
|
|
if ($DebuggerScript -and -not (Test-Path $DebuggerScript)) {
|
|
throw "DebuggerScript not found: $DebuggerScript"
|
|
}
|
|
|
|
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 = if ($DebuggerScript) {
|
|
$cdb = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe'
|
|
if (-not (Test-Path $cdb)) { throw "cdb.exe not found at $cdb (install the Windows Debugging Tools)." }
|
|
Start-Process -FilePath $cdb `
|
|
-ArgumentList @('-G', '-o', '-lines', '-cf', (Resolve-Path $DebuggerScript).Path, $exe) `
|
|
-RedirectStandardOutput $log -RedirectStandardError "$log.err" -PassThru -WindowStyle Hidden
|
|
} else {
|
|
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
|