feat(render): Vulkan campaign V11 step 4 — retire GL from CI and gate scripts

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>
This commit is contained in:
Erik 2026-07-29 03:06:45 +02:00
parent 7a0227c12e
commit 5852bdb877
8 changed files with 72 additions and 604 deletions

View file

@ -1,77 +0,0 @@
# Campaign V slice V7: the connected route the GL-versus-Vulkan differential
# runs TWICE, once per backend, pairing the PNGs by name.
#
# Two separate launches have to agree pixel-for-pixel, which is a much stricter
# requirement than either connected route was written for. Everything below is
# in service of that:
#
# * Every teleloc carries the identity quaternion, so the heading at each stop
# is the same on both runs rather than whatever the previous heading was.
# * The world clock is pinned by ACDREAM_WORLD_TIME, which the gate script
# forces on both launches. It used to be pinned here instead, by pressing
# AcdreamCycleTimeOfDay three times (live -> 0.00 -> 0.25 -> 0.50), and
# THAT DID NOT WORK: SetDebugTime is transient by design -- it is the /time
# slash command's mechanism, and WorldTimeService.SyncFromServer clears it
# -- so ACE's next TimeSync un-pinned the clock seconds later, long before
# the first stop. V7 measured the consequence directly: two captures 45 s
# apart at ONE stop on ONE backend differ in 22.3% of the frame outdoors and
# 11.8% indoors, because the sun keeps moving. The presses are gone.
# * Nothing moves the character. The only thing between arrival and capture is
# a settle window, because a timed turn cannot be relied on to stop at the
# same angle twice.
# * There is no `checkpoint` verb here. The differential's verdict is the
# paired PNG, and the ownership ledger has its own gate
# (run-connected-world-lifecycle-gate.ps1) that this route does not duplicate.
#
# MSAA is forced OFF by the gate script rather than here, on BOTH launches.
# Sample positions are not specified across implementations and cost 8.8% of the
# frame at 4x -- measured, plan section 5.5.16 -- so a differential run with MSAA
# on measures the two drivers' sample patterns and nothing else.
#
# Residual divergence this route cannot remove, and which is a finding rather
# than a failure: PHASE. Two launches cannot agree on an emitter's age, a
# creature's animation cursor, a portal's scrolling texture, or the cloud
# sheet's position -- the cloud sheet in particular advances with WALL time and
# not with the Dereth clock the override above pins, which is why the offline
# pixel gate masks its own top 280 rows. The V6m smoke run measured the shape
# of it at stop 1 with MSAA off: 18.52% of the frame differed, 89% of that in
# the top 240 rows (cloud sheet, treeline, the portal), and rows 300-720 --
# terrain, blending, roads, buildings, character, entire retained UI --
# contributed about 9,500 pixels between them, almost all of it thin outlines
# on silhouette edges. That is why every stop below is chosen for its static
# content and why the settle windows are long.
wait world-ready 90000
wait world-visible 30000
sleep 8000
# 1. Holtburg: the dense outdoor town. Terrain, terrain blending, the road
# overlay, static world meshes, scenery and the whole retained UI in one
# frame. This is the SMOKE stop -- the one pair -Smoke compares on its own.
command /teleloc 0xA9B40019 84.0 7.1 94.005 1 0 0 0
wait materialized 1 90000
wait world-visible 30000
sleep 25000
screenshot holtburg_town 15000
# 2. Facility Hub: an interior EnvCell, and the durable fix for the campaign's
# oldest coverage gap. Plan section 5.1 records that the offline gate sees no
# EnvCell at all and that EnvCellRenderer's Vulkan arm is proven by one
# eyes-on frame; this stop is the first automated instrument that looks at
# one. Cell 0x164 is >= 0x100, which is what makes it indoor: walls, floor,
# per-cell lighting and the doorway clip region rather than terrain.
command /teleloc 0x8A020164 70.35 -40.66 -5.9 1 0 0 0
wait materialized 2 90000
wait world-visible 30000
sleep 25000
screenshot facility_hub_interior 15000
# 3. Aerlinthe: dense island geometry, the water edge and a long view. The
# stop that exercises fog and the far end of the depth range, where a
# precision divergence between the two backends would show first.
command /teleloc 0x09040008 11.4 188.6 87.705 1 0 0 0
wait materialized 3 90000
wait world-visible 30000
sleep 25000
screenshot aerlinthe_island 15000
sleep 3000

View file

@ -1,421 +0,0 @@
<#
.SYNOPSIS
Campaign V slice V7 instrument: the GL-versus-Vulkan connected differential.
.DESCRIPTION
Runs the SAME connected route twice against the local ACE server -- once with
ACDREAM_RENDER_BACKEND=gl, once with =vulkan -- pairs the screenshots by name,
compares each pair with the `compare-screenshots` CLI at the plan's pinned
tolerance, and prints one verdict table.
This is the instrument for plan section 2's pixel-acceptance contract:
compare-screenshots expected.png actual.png out.json
channel tolerance 2, maximum differing fraction 0.001,
MSAA off, ACDREAM_DAY_GROUP pinned,
at every deterministic checkpoint of the connected route.
FOUR THINGS ARE FORCED, and each is load-bearing.
1. ACDREAM_MSAA_SAMPLES=0 ON BOTH LAUNCHES. Multisample resolve positions
are explicitly unspecified across implementations. Plan section 5.5.16
MEASURED what leaving them on costs: 8.83% of the frame differed at 4x,
81,359 pixels of 921,600, essentially all of it hugging foliage and
silhouette edges. That is two drivers' sample patterns, not a renderer
divergence, and it swamps the 0.001 threshold by ninety-fold. Forcing it
off on both runs is what makes the remaining difference attributable.
Note that it must be forced on BOTH -- matching a GL run at 4x against a
Vulkan run at 0 would be worse than either.
2. ACDREAM_DAY_GROUP and ACDREAM_WORLD_TIME pinned. The Dereth clock
advances with wall time, and it does not only move the sky: it moves the
SUN, so every lit surface in the scene drifts with it. The route used to
press AcdreamCycleTimeOfDay three times instead, and that was measured
NOT to hold -- SetDebugTime is transient by design and ACE's next
TimeSync clears it within seconds. What that cost, measured at V7 on two
captures 45 s apart at ONE stop on ONE backend: 22.3% of the frame at
Holtburg and 11.8% inside the Facility Hub. A cross-backend number taken
against that noise floor means nothing, which is why the day fraction is
now pinned by env var and the route no longer presses anything.
3. ACDREAM_SKY_PHASE_SECONDS pinned ON BOTH LAUNCHES. The day group and the
time-of-day override pin ONE of the sky's two clocks -- the one that
chooses the keyframe and the sun angle. The cloud sheet runs on the
other: TexVelocityX/Y accumulate against real elapsed time by design,
because retail's clouds drift independently of the date, so no amount of
world-clock pinning makes two launches agree about where they are. The
V6m smoke pair measured the cost of leaving it: 89% of an 18.52%
whole-frame difference lived in the top 240 rows. Pinning the phase is
instrument determinism -- it is off in every ordinary run, it changes one
input to a UV offset, and it is what lets this gate keep the SKY under
strict comparison instead of masking it, which is what -MaskTopPixels
would have cost.
4. A DESKTOP WITNESS per run, with the guards the repeat-run gate
(run-repeat-connected-gate.ps1) learned the hard way. The client's own
capture is the differential's subject, but a blank frame reads back as a
valid PNG (plan section 5.5.2), so a second instrument that shares
nothing with the renderer decides whether the run is worth comparing at
all. Its failure modes are the same three that gate documents:
* an overlapping window or a locked screen -- CopyFromScreen grabs
whatever is composited at those coordinates, so run this with
nothing else on the primary monitor and the desktop unlocked;
* stray input -- a foregrounded window takes keystrokes and scrolls,
which moves the camera off the pinned stop and produces an enormous
spurious difference. The client log is scanned for camera-affecting
input afterwards and the run is aborted if any arrived;
* a hard kill -- ACE holds the session about three minutes and the
SECOND launch of this gate is the one that pays for it, so both runs
close through WM_CLOSE.
.PARAMETER Out
Directory for both captures, the comparison reports and the verdict JSON.
.PARAMETER Route
The route both backends run. Defaults to the differential route, which is
written for two-launch determinism -- see its header before substituting one.
.PARAMETER Smoke
Compare only the FIRST stop. The route is truncated after its first
`screenshot` verb, so the run is short and the artifact records exactly what
ran. Use this to measure the distance between the backends without paying
for the whole route; the full route is the V7 gate.
.PARAMETER Tolerance
Per-channel absolute difference allowed. Default 2, the plan's pinned value.
.PARAMETER MaxDifferentFraction
Fraction of differing pixels allowed. Default 0.001, likewise pinned.
.PARAMETER DayGroup
Sky day-group index pinned on both launches. Default 0.
.PARAMETER WorldDayFraction
Dereth day fraction pinned on both launches, in [0, 1). Default 0.5 -- noon,
the brightest and flattest lighting the day has, which is what the route's
three AcdreamCycleTimeOfDay presses were aiming at before it was measured
that they do not hold. See forced item 2 above.
.PARAMETER SkyPhaseSeconds
Elapsed-seconds value the sky's UV scroll is pinned to on both launches.
Default 0 -- the cloud sheet's authored origin. Any finite value works: the
offset is taken modulo 1 per axis. See forced item 3 above.
.PARAMETER MaskTopPixels
Height in pixels of a top band excluded from every comparison. DEFAULT 0 --
nothing is masked, and the gate is a strict identity check on the whole
frame.
It exists because the V6m smoke run measured what the band costs and the
number belongs where the next reader will find it. With the world clock
pinned by the route and MSAA off, a GL-versus-Vulkan pair at Holtburg
differed in 18.52% of the frame -- and 89% of that difference sat in the top
240 rows, which is the scrolling cloud sheet plus the treeline it sits
behind. The cloud sheet advances with WALL time, not the Dereth clock, so
two launches minutes apart can never agree there; this is the same
population the offline pixel gate masks its top 280 rows for. Rows 300-720
-- terrain, blending, roads, buildings, the character and the whole retained
UI -- contributed about 9,500 differing pixels between them.
Whether V7 accepts a mask, pins the cloud phase, or fixes something the
smoke was actually seeing is V7's decision, not this script's, which is why
the default is off. Do NOT reach for this to make a failing gate pass: plan
section 7.1 rule 2.
.PARAMETER MinRenderedBytes
Desktop-grab PNG size below which a run is called blank and the comparison
is refused. Default 500000, the repeat gate's measured separator.
.PARAMETER CooldownSeconds
Wait between the two launches so ACE clears the first graceful logout.
Default 15.
.EXAMPLE
tools/run-backend-differential-gate.ps1 -Out artifacts/v7-differential -Smoke
tools/run-backend-differential-gate.ps1 -Out artifacts/v7-differential
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Out,
[string]$Route,
[switch]$Smoke,
[int]$Tolerance = 2,
[double]$MaxDifferentFraction = 0.001,
[int]$DayGroup = 0,
[double]$WorldDayFraction = 0.5,
[double]$SkyPhaseSeconds = 0,
[int]$MaskTopPixels = 0,
[int]$MinRenderedBytes = 500000,
[int]$CooldownSeconds = 15,
[int]$RouteTimeoutSeconds = 600,
[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'
if (-not $Route) { $Route = Join-Path $repo 'tools\connected-backend-differential.route.txt' }
function Write-Step($message) { Write-Host "[differential] $message" }
# --- Preconditions ------------------------------------------------------------
if (Get-Process -Name AcDream.App -ErrorAction SilentlyContinue) {
throw 'AcDream.App is already running. This gate uses the shared test account and must not steal its session.'
}
if (@(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue).Count -eq 0) {
throw 'local ACE is not listening on UDP port 9000.'
}
if (-not (Test-Path $Route)) { throw "Route not found: $Route" }
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." }
if (-not (Test-Path $cli)) { throw "AcDream.Cli not found at $cli." }
if (Test-Path $Out) { Remove-Item -Recurse -Force $Out }
New-Item -ItemType Directory -Force -Path $Out | Out-Null
# The route both runs execute. -Smoke truncates after the first capture; the
# truncated file is written into the artifact directory so what ran is on record.
$routeLines = Get-Content -LiteralPath $Route
if ($Smoke) {
$firstShot = -1
for ($i = 0; $i -lt $routeLines.Count; $i++) {
if ($routeLines[$i].TrimStart().StartsWith('screenshot ')) { $firstShot = $i; break }
}
if ($firstShot -lt 0) { throw "The route has no screenshot verb to truncate at: $Route" }
$routeLines = @($routeLines[0..$firstShot]) + @('sleep 3000')
Write-Step "smoke mode: route truncated after its first capture ($($firstShot + 1) lines)"
}
$effectiveRoute = Join-Path $Out 'route.txt'
Set-Content -Encoding utf8 -Path $effectiveRoute -Value $routeLines
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class DifferentialGateSurface {
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hWnd, out RECT r);
[DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr hWnd, ref POINT p);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
}
'@
function Invoke-BackendRun([string]$Backend) {
$dir = Join-Path $Out $Backend
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$log = Join-Path $dir 'client.log'
$grab = Join-Path $dir 'desktop-grab.png'
$env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call"
$env:ACDREAM_LIVE = '1'
$env:ACDREAM_TEST_HOST = '127.0.0.1'
$env:ACDREAM_TEST_PORT = '9000'
$env:ACDREAM_TEST_USER = 'testaccount'
$env:ACDREAM_TEST_PASS = 'testpassword'
$env:ACDREAM_RETAIL_UI = '1'
$env:ACDREAM_DEVTOOLS = '0'
$env:ACDREAM_DAY_GROUP = "$DayGroup"
# Both launches, always. See the .DESCRIPTION notes 1 and 3.
$env:ACDREAM_MSAA_SAMPLES = '0'
$env:ACDREAM_SKY_PHASE_SECONDS =
$SkyPhaseSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture)
$env:ACDREAM_WORLD_TIME =
$WorldDayFraction.ToString([System.Globalization.CultureInfo]::InvariantCulture)
$env:ACDREAM_RENDER_BACKEND = $Backend
$env:ACDREAM_UI_PROBE_SCRIPT = $effectiveRoute
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir
Write-Step "launching $Backend (MSAA off, day group $DayGroup)"
# Normal window style: the desktop witness needs a composited frame.
$proc = Start-Process -FilePath $exe -WorkingDirectory $repo `
-RedirectStandardOutput $log -RedirectStandardError "$log.err" -PassThru
$completed = $false
$deadline = (Get-Date).AddSeconds($RouteTimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Select-String -Path $log -SimpleMatch '[UI-PROBE] UI probe script complete' `
-ErrorAction SilentlyContinue) { $completed = $true; break }
if ($proc.HasExited) { break }
Start-Sleep -Milliseconds 1000
}
# The desktop witness, taken while the client is still in its trailing sleep
# and therefore still rendering the last scene of the route.
$grabBytes = 0
if (-not $proc.HasExited) {
try {
$proc.Refresh()
$h = $proc.MainWindowHandle
if ($h -ne [IntPtr]::Zero) {
[DifferentialGateSurface]::ShowWindow($h, 9) | Out-Null # SW_RESTORE
[DifferentialGateSurface]::SetForegroundWindow($h) | Out-Null
Start-Sleep -Milliseconds 1200
$r = New-Object DifferentialGateSurface+RECT
[DifferentialGateSurface]::GetClientRect($h, [ref]$r) | Out-Null
$p = New-Object DifferentialGateSurface+POINT
[DifferentialGateSurface]::ClientToScreen($h, [ref]$p) | Out-Null
$w = $r.R - $r.L; $ht = $r.B - $r.T
if ($w -gt 0 -and $ht -gt 0) {
$bmp = New-Object System.Drawing.Bitmap $w, $ht
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.CopyFromScreen($p.X, $p.Y, 0, 0, (New-Object System.Drawing.Size $w, $ht))
$bmp.Save($grab, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
$grabBytes = (Get-Item $grab).Length
}
}
}
catch { Write-Step "desktop grab failed on ${Backend}: $_" }
}
# Graceful close, always: a hard kill leaves ACE holding the session about
# three minutes, and the second launch is the one that would pay for it.
$app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
if ($app) {
$app.CloseMainWindow() | Out-Null
if (-not $app.WaitForExit(20000)) {
Write-Step "WM_CLOSE timed out on $Backend; forcing"
$app | Stop-Process -Force
}
}
Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue
Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue
Remove-Item Env:\ACDREAM_SKY_PHASE_SECONDS -ErrorAction SilentlyContinue
Remove-Item Env:\ACDREAM_WORLD_TIME -ErrorAction SilentlyContinue
$strayInput = @(Select-String -Path $log -Pattern 'ScrollUp|ScrollDown|ZoomIn|ZoomOut|CameraZoom' `
-CaseSensitive -ErrorAction SilentlyContinue)
$witness = if ($grabBytes -ge $MinRenderedBytes) { 'RENDERED' }
elseif ($grabBytes -gt 0) { 'BLANK' }
else { 'NO-CAPTURE' }
return [pscustomobject][ordered]@{
Backend = $Backend
RouteCompleted = $completed
GrabBytes = $grabBytes
Witness = $witness
StrayInputLines = @($strayInput | Select-Object -First 5 | ForEach-Object { $_.Line })
Screenshots = Join-Path $dir 'screenshots'
Log = $log
}
}
$gl = Invoke-BackendRun 'gl'
Write-Step "cooldown ${CooldownSeconds}s so ACE clears the graceful logout"
Start-Sleep -Seconds $CooldownSeconds
$vk = Invoke-BackendRun 'vulkan'
# --- Sanity, before any pixel is compared ------------------------------------
$aborts = @()
foreach ($run in @($gl, $vk)) {
if (-not $run.RouteCompleted) { $aborts += "$($run.Backend): the route did not complete" }
if ($run.Witness -ne 'RENDERED') {
$aborts += "$($run.Backend): desktop witness says $($run.Witness) ($($run.GrabBytes) B) -- locked screen, an overlapping window, or a blank frame"
}
if ($run.StrayInputLines.Count -gt 0) {
$aborts += "$($run.Backend): camera-affecting input reached the window; the capture is not comparable"
}
}
$pairs = @()
if ($aborts.Count -eq 0) {
$glShots = @(Get-ChildItem $gl.Screenshots -Filter *.png -ErrorAction SilentlyContinue)
if ($glShots.Count -eq 0) { $aborts += 'gl: no screenshots were captured' }
# The comparer skips any pixel whose mask alpha is non-zero, so the excluded
# band is opaque and everything compared is transparent. Same construction as
# the offline pixel gate's sky mask; off unless -MaskTopPixels was given.
$maskPath = $null
if ($MaskTopPixels -gt 0 -and $glShots.Count -gt 0) {
$probeImage = [System.Drawing.Bitmap]::FromFile($glShots[0].FullName)
$mw = $probeImage.Width; $mh = $probeImage.Height
$probeImage.Dispose()
$mask = New-Object System.Drawing.Bitmap($mw, $mh, [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, $mw, [Math]::Min($MaskTopPixels, $mh))
$graphics.Dispose(); $opaque.Dispose()
$maskPath = Join-Path $Out 'mask.png'
$mask.Save($maskPath, [System.Drawing.Imaging.ImageFormat]::Png)
$mask.Dispose()
Write-Step "masking the top $MaskTopPixels rows of every pair"
}
foreach ($shot in $glShots) {
$counterpart = Join-Path $vk.Screenshots $shot.Name
if (-not (Test-Path $counterpart)) {
$pairs += [pscustomobject][ordered]@{
Stop = $shot.BaseName; Verdict = 'UNPAIRED'
DifferentPixels = $null; Fraction = $null; MaxChannelDelta = $null
}
continue
}
$report = Join-Path $Out "compare-$($shot.BaseName).json"
if ($maskPath) {
& dotnet $cli compare-screenshots $shot.FullName $counterpart $report $Tolerance $MaxDifferentFraction $maskPath | Out-Null
}
else {
& dotnet $cli compare-screenshots $shot.FullName $counterpart $report $Tolerance $MaxDifferentFraction | Out-Null
}
$verdict = Get-Content $report -Raw | ConvertFrom-Json
$outcome = if ($verdict.passed) { 'MATCH' } else { 'DIVERGED' }
$pairs += [pscustomobject][ordered]@{
Stop = $shot.BaseName
Verdict = $outcome
DifferentPixels = $verdict.differentPixels
Fraction = $verdict.differentPixelFraction
MaxChannelDelta = $verdict.maximumChannelDelta
}
}
}
# --- Verdict ------------------------------------------------------------------
Write-Host ''
if ($aborts.Count -gt 0) {
Write-Host '[differential] ABORTED before comparison:' -ForegroundColor Red
$aborts | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
}
else {
$pairs | Format-Table -AutoSize | Out-String | Write-Host
}
$diverged = @($pairs | Where-Object Verdict -ne 'MATCH')
$report = [pscustomobject][ordered]@{
Passed = ($aborts.Count -eq 0 -and $diverged.Count -eq 0)
Smoke = [bool]$Smoke
Commit = (& git -C $repo rev-parse HEAD).Trim()
Route = $Route
EffectiveRoute = $effectiveRoute
ChannelTolerance = $Tolerance
MaxDifferentPixelFraction = $MaxDifferentFraction
MsaaSamples = 0
DayGroup = $DayGroup
WorldDayFraction = $WorldDayFraction
SkyPhaseSeconds = $SkyPhaseSeconds
MaskTopPixels = $MaskTopPixels
Runs = @($gl, $vk)
Pairs = @($pairs)
Aborts = @($aborts)
}
$reportPath = Join-Path $Out 'differential.json'
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportPath -Encoding utf8
Write-Step "report: $reportPath"
if ($aborts.Count -gt 0) { exit 2 }
if ($diverged.Count -gt 0) {
Write-Host ("[differential] FAILED: {0}/{1} stop(s) diverged." -f $diverged.Count, $pairs.Count) -ForegroundColor Red
exit 1
}
Write-Step ("PASS: {0}/{0} stop(s) match at tolerance {1} / fraction {2}." -f $pairs.Count, $Tolerance, $MaxDifferentFraction)
exit 0

View file

@ -262,12 +262,12 @@ function Invoke-Session(
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir
$env:ACDREAM_DUMP_MOVE_TRUTH = $null
$env:ACDREAM_WB_DIAG = $null
# Campaign V slice V10: cleared, not set. This route is renderer-agnostic and
# its job after the cutover is to exercise whatever backend the process
# defaults to -- which is Vulkan. An ambient override inherited from the
# caller's shell would quietly turn that into a GL run reported as a default
# one, so the variable is cleared the same way every other unwanted knob here
# is. Use ACDREAM_RENDER_BACKEND on a manual launch to reach the escape hatch.
# Campaign V slice V10 cleared this rather than setting it, because the route
# is renderer-agnostic and its job after the cutover was to exercise whatever
# backend the process defaults to -- which was Vulkan even before Campaign V
# slice V11 deleted the GL arm this variable used to select. The clear stays
# harmless now that nothing reads it: cleared the same way every other
# unwanted knob here is, for a variable that is never set by this gate again.
$env:ACDREAM_RENDER_BACKEND = $null
$env:ACDREAM_COLLISION_SHADOW_EVERY =
if ($CollisionShadowEvery -gt 0) { "$CollisionShadowEvery" } else { $null }

View file

@ -16,13 +16,10 @@
those still need the connected route, so slices touching them (V4e, V4g)
must additionally be gated by the user.
Determinism levers, all four forced here rather than left to the ambient
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_RENDER_BACKEND -- see -Backend. Since slice V10 flipped the
process default, "whatever the environment says" is no longer a stable
description of what got captured.
* 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
@ -32,10 +29,10 @@
* 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 for the same reason the backend
differential forces it: 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.
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.
@ -46,15 +43,6 @@
Optional. A directory from a previous capture. When supplied, every PNG is
compared against its namesake and the script fails on any mismatch.
.PARAMETER Backend
Which backend to capture with. DEFAULT 'vulkan', following slice V10's
cutover: the gate must capture what the client actually ships.
Pass 'gl' to capture through the escape hatch -- which is also how this
script produces the GL-era left-hand side of a cross-backend comparison, and
how the escape hatch itself gets verified. Pass '' to let the process default
decide, which is only useful for proving what that default is.
.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.
@ -71,8 +59,7 @@
.PARAMETER WorldDayFraction
Dereth day fraction pinned on the launch, in [0, 1). Default 0.5 -- noon, the
brightest and flattest lighting the day has, and the value the backend
differential pins.
brightest and flattest lighting the day has.
.PARAMETER SkyPhaseSeconds
Elapsed-seconds value the sky's UV scroll is pinned to. Default 0, the cloud
@ -122,17 +109,11 @@
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
.EXAMPLE
# Cross-backend: the GL-era capture, then Vulkan against it.
tools/run-offline-pixel-gate.ps1 -Out artifacts/v10-gl -Backend gl
tools/run-offline-pixel-gate.ps1 -Out artifacts/v10-vk -Baseline artifacts/v10-gl
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Out,
[string]$Baseline,
[string]$Backend = 'vulkan',
[int]$WarmupMs = 12000,
[int]$DayGroup = 0,
[double]$WorldDayFraction = 0.5,
@ -191,11 +172,8 @@ $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 }
if ($Backend) { $env:ACDREAM_RENDER_BACKEND = $Backend }
else { Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue }
$backendLabel = if ($Backend) { $Backend } else { 'process default' }
Write-Step "launching offline client (backend $backendLabel, warmup ${WarmupMs}ms, day group $DayGroup, day fraction $WorldDayFraction, sky phase $SkyPhaseSeconds, MSAA $MsaaSamples)"
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
@ -231,7 +209,6 @@ finally {
$app | Stop-Process -Force
}
}
Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue
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

View file

@ -3,9 +3,13 @@
Campaign V: one offline Vulkan capture with the validation layer proven loaded.
.DESCRIPTION
The same deterministic offline scene the GL pixel gate captures, rendered on
ACDREAM_RENDER_BACKEND=vulkan. VK_LOADER_DEBUG=layer makes the loader print
the layer it inserts, so "validation was on" is evidence rather than a claim.
The same deterministic offline scene run-offline-pixel-gate.ps1 captures.
Before Campaign V slice V10 flipped the process default, this needed
ACDREAM_RENDER_BACKEND=vulkan to reach the Vulkan arm at all; V11 then
deleted the GL arm the variable used to select, so it is now a no-op left
for the record rather than something this script depends on.
VK_LOADER_DEBUG=layer makes the loader print the layer it inserts, so
"validation was on" is evidence rather than a claim.
#>
[CmdletBinding()]
param(

View file

@ -1,15 +1,15 @@
<#
.SYNOPSIS
Campaign V slice V11 step 0: the #256/#257 discriminator, run on both backends.
Repeated-portal-churn regression soak: memory growth and visual drift.
.DESCRIPTION
Issues #256 (server-spawned signs and portals go invisible after repeated
portal runs, while staying interactive) and #257 (working set grows to
~1.5 GB over the same session) were both observed on the Vulkan backend in
one long live session. Both filings demand the SAME discriminator, and both
demand it BEFORE V11 deletes the OpenGL backend:
run the same repeated-churn route on GL and on Vulkan.
Originated as Campaign V slice V11 step 0: the #256/#257 discriminator, run
on both the GL and Vulkan backends before V11 deleted the former. Issues
#256 (server-spawned signs and portals go invisible after repeated portal
runs, while staying interactive) and #257 (working set grows to ~1.5 GB
over the same session) were both observed on the Vulkan backend in one long
live session, and running the same repeated-churn route on both arms told
the campaign whether either bug predated Vulkan or was new to it:
growth / drift on BOTH arms -> a pre-existing publication or resource
lifetime bug that the campaign merely
@ -17,11 +17,17 @@
growth / drift on VULKAN ONLY -> the new arm's resource lifecycle, and
deleting its predecessor would destroy
the only reference implementation that
can answer the question.
could answer the question.
After V11 the GL arm does not exist and this question costs far more to
answer, which is why this script runs first and why its verdict can stop the
slice.
Campaign V slice V11 deleted the GL backend, so that specific
before-and-after question can no longer be asked of any future bug but
the soak itself does not depend on having two arms. Comparing a fresh
Holtburg capture against a much-later one from the SAME arm is still the
closest automated instrument to what a user's long session actually shows,
and the memory/entity/GPU trend analysis below never needed a second arm to
begin with. -Backends now defaults to Vulkan alone; pass additional values
only to compare arms that still exist (a future backend, a driver
variant) 'gl' is no longer a valid launch and the app ignores it.
WHAT IT MEASURES, per stop, per cycle:
@ -58,10 +64,10 @@
determinism levers the differential gate forces are forced here for the same
reason -- an unpinned sun or cloud sheet would swamp the signal.
NOT A PIXEL GATE. This script never compares the two backends to each other;
that is run-backend-differential-gate.ps1's job and its thresholds are the
campaign's. Here each arm is compared only with itself, and the verdict is
about growth and drift.
NOT A PIXEL GATE. This script never compares two backends to each other
even when -Backends names more than one arm, each is still compared only
with itself. The verdict is about growth and drift within an arm, not
parity between arms.
.PARAMETER Out
Directory for both arms' logs, checkpoints, captures, samples and the verdict.
@ -72,8 +78,8 @@
warmup", and three stops per cycle makes 90 transits at the default.
.PARAMETER Backends
Which arms to run, in order. Default gl then vulkan, so the arm under
suspicion runs second and cannot be blamed for warming the machine.
Which arms to run, in order. Default 'vulkan' alone the only backend
that exists since Campaign V slice V11 deleted the GL arm.
.PARAMETER ShotCycles
Cycles on which Holtburg is captured. Default 1, 10, 20 and the last cycle.
@ -89,7 +95,7 @@
param(
[Parameter(Mandatory = $true)][string]$Out,
[int]$Cycles = 30,
[string[]]$Backends = @('gl', 'vulkan'),
[string[]]$Backends = @('vulkan'),
[int[]]$ShotCycles,
[int]$SettleMilliseconds = 6000,
[int]$ShotSettleMilliseconds = 12000,
@ -150,7 +156,7 @@ $stops = @(
)
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add('# GENERATED by tools/run-portal-churn-soak.ps1 -- the V11 step-0 discriminator.')
$lines.Add('# GENERATED by tools/run-portal-churn-soak.ps1 -- the portal-churn regression soak.')
$lines.Add("# $Cycles cycles x $($stops.Count) stops. Do not hand-edit; regenerate.")
$lines.Add('')
$lines.Add('wait world-ready 90000')

View file

@ -112,11 +112,12 @@ sleep 20000
$env:ACDREAM_RETAIL_UI = '1'
$env:ACDREAM_UI_PROBE_SCRIPT = Join-Path $dir 'probe.txt'
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir
# Campaign V slice V10: deliberately cleared rather than set. The blank-world
# defect this gate exists for is a property of whichever backend the client
# actually starts, so the gate must launch the process default -- Vulkan
# after the cutover -- and an ambient override from the caller's shell would
# silently make it a GL run wearing the default's report.
# Campaign V slice V10 deliberately cleared this rather than setting it,
# because the blank-world defect this gate exists for is a property of
# whichever backend the client actually starts, and the gate had to launch
# the process default -- Vulkan after the cutover, and the ONLY backend
# since Campaign V slice V11 deleted the GL arm this variable used to
# select. The clear is now a no-op kept for the record.
Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue
$log = Join-Path $dir 'client.log'