diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index c42ad18e..7e9528f4 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -749,7 +749,163 @@ screenshot-byte verdict it uses is now known to be measuring the wrong thing. The next instrument should be an occlusion query (`GL_SAMPLES_PASSED`) around the world pass, read back a frame later so it adds no sync point — that separates "the draws never executed" from "they executed and their output was discarded", -which is the remaining fork. +which is the remaining fork. **That instrument was built and run — see §5.5.3, +which supersedes this section's "shared 3-D state" framing.** + +#### 5.5.3 The occlusion-query verdict (2026-07-28): the GPU stops reporting + +The instrument §5.5.2 asked for was built and run on a V4c tree staged from +`git revert --no-commit 543bc79f` (never committed), with `GL_SAMPLES_PASSED` +bracketing three sites — the raw-GL terrain draw, the dispatcher's entity draws, +and the retained-UI flush — and the counts collected later, never in the frame +that issued them. The desktop witness was the verdict throughout. All probe code +was stripped before this commit; the apparatus changes that survive are the two +gate scripts, now asserting on the desktop grab. + +**Building it turned up a fourth instrument fault, and it is the sharpest one.** +The obvious readback — `glGetQueryObject` into client memory, guarded by +`GL_QUERY_RESULT_AVAILABLE` and read a frame late — **deadlocks the client on +V4c.** Four consecutive runs wedged at the first frame that draws the world, and +five `dotnet-stack` samples taken four seconds apart all show the render thread +inside the driver under `GlDrawCounterProbe.Drain`, i.e. blocked in +`glGetQueryObject`. It is not a probe defect: the identical probe ran 4,420 +frames on the V4c *parent* with normal counts and a normal 5 ms frame time, and +the wedge does not need the world sites at all — instrumenting only the UI flush +reproduces it, while creating the query objects and never beginning one does not. +So on V4c, the mere existence of an outstanding occlusion query is enough to make +a CPU-side result read never return. + +The way past that is to never ask the driver for a result on the CPU timeline. +The query result is instead written into a persistently-mapped, coherent +`GL_QUERY_BUFFER`: `glGetQueryObject` with that buffer bound performs the write +on the GPU timeline, so no client wait is possible by construction, and +pre-filling each slot with a sentinel makes "the GPU reported zero samples" and +"the GPU never reached this command" different observations. That instrument does +not wedge, and it produced the table below. + +**Per-frame counters, blank versus rendered, four runs on one V4c binary** — +three blank and one rendered on the desktop witness, both instruments agreeing on +the label in every run: + +| | terrain | entities | UI | frames logged | +|---|---|---|---|---| +| Rendered run (run 3) | 1,718,771 | ~312,600 | 541,445 | 1,068, **zero** no-result | +| Blank runs (1, 2, 4), steady state | no result | no result | no result | ~950 each, **every** query | +| Blank runs, frame 43 (the one early world frame) | 1,692,830 | 317,561 | no result | — | +| Parent build, same probe, frame 45 | 1,692,830 | 317,561 | 539,0xx | 4,420, zero pending | + +Four things follow, and they are worth separating. + +**1. The mission's three-way fork resolves to the third branch — but not as +"submission stalls".** On a blank run no query result is ever produced, at any +site, for the whole run. It is emphatically not "zero samples": the sentinel is +untouched, so the GPU never executed the write. And yet the process is not +stalled — frame time stays at a steady 5.5 ms for ~3,700 frames, the frame-flight +fences keep retiring (`GpuFrameFlightController.RetireFence` spins on +`glClientWaitSync` until the fence signals, so a stalled submission would freeze +the client outright), and the compositor keeps showing the clear colour and the +complete retained UI. The GPU is running the frame. What has stopped is +everything the GPU is asked to *report*. + +**2. The failure is total, not 3-D-specific.** The UI query dies on a blank run +too — in the same frames where the desktop witness plainly shows the UI on +screen. §5.5.2 read the symptom as "something shared by every depth-tested draw +is poisoned"; that framing is now too narrow. Every GPU→CPU reporting channel +tested is dead on a blank run — `glReadPixels` of framebuffer 0 returns +RGBA(0,0,0,0) even over visible UI pixels, a CPU query read blocks forever, a +GPU-timeline query write never lands — while the two channels that carry no +result, fence signalling and present, keep working. The common factor is the +*direction*: nothing comes back. + +**3. The transition is a single sharp event at the first world frame, and it is +irreversible.** In every blank run the UI query returns normal counts (539,010) +for frames 1–42, the world draws for the first time at frame 43, and from that +frame on nothing is ever reported again — 924 consecutive dead frames in run 1. +The rendered run has no world draw at frame 43 (its first is frame 1,080) and +never loses a single result. This is the same "after the first world frame" +boundary §5.5 recorded from the colour reads, now measured on a second, +independent channel. + +**4. That first world frame rasterizes correctly — identically on both builds.** +Frame 43 reports 1,692,830 terrain samples and 317,561 entity samples on V4c, and +the parent's first world frame reports **the same two numbers**. The world is +drawn, in full, exactly as the good build draws it. It is the last thing the GPU +ever tells this process, and V4c is what decides whether that is the last thing. + +**Where this leaves the mechanism.** Everything now points at the GPU→CPU +reporting path for this context collapsing at the first world frame, with V4c's +submission pattern as the trigger and nothing in V4c's own state as the cause — +V4c does not touch the terrain draw, does not touch the UI flush, and §5.5.2 +already showed its renderer state is bit-identical on blank and rendered frames. +A clean context with `glGetGraphicsResetStatus` = `NO_ERROR` on 1,814 samples +does not lose its readback, its query results, and its ability to answer a query +without blocking, all at once, because of anything expressible in the API. **No +fix was attempted and V4c is still not re-landed.** + +#### 5.5.4 Strategic options + +Three ways forward, with the evidence for each. + +**(A) Keep hunting for a V4c-side trigger we can remove.** The attribution is +solid (§5.5.1: 4/5 versus 0/5 interleaved, p ≈ 0.024), so a trigger exists in the +V4c diff and removing it would restore the no-fork plan. Against it: five +mechanisms have now been falsified — the ring's `glBufferSubData` hazard, a +capture-FBO leak, reveal ordering, shared 3-D state (depth plane, camera, clip +distances, context reset), and CPU-side visibility — and the two facts that +remain are *not expressible in the API*, which is exactly the shape of a hunt +with no bottom. The remaining honest step would be a RenderDoc or GPU-crash-dump +capture of the frame-43 boundary, or a bisect of the V4c diff into ~6 sub-commits +each measured at 5 runs, which is roughly 3 hours of connected machine time per +round and pins the user's machine for it. + +**(B) Accept it as an AMD GL driver defect, keep the world on the legacy raw-GL +path on the GL backend, and carry the V4c/V4d RHI ports forward for Vulkan +only.** This is what the evidence supports: a defect that (i) follows a +submission-pattern change, (ii) is invisible to every API-level state query, +(iii) kills three unrelated readback channels simultaneously while leaving +present and fences intact, and (iv) can be induced *harder* by adding a +perfectly legal occlusion query, is a driver defect in 26.6.4 on the RX 9070 XT, +not an application bug. The cost is real and must be stated plainly: it breaks +§3.1's no-fork rule for the world path, so the GL backend keeps raw-GL world +renderers while Vulkan gets RHI ones, and V4h's "seam complete" milestone can no +longer mean "nothing raw-GL remains". V7's GL-versus-Vulkan differential then +compares a raw-GL world against an RHI world rather than one contract against +two backends, which weakens it precisely where it is most valuable. It also +leaves the deleted-at-V11 GL path carrying code the campaign intended to retire +early. + +**(C) Reorder the campaign: bring Vulkan up first (V5/V6) and decide V4c +afterwards.** This is the option the evidence actually suggests and it is not on +the original menu. The whole point of V4c is to make the world path +backend-agnostic; its only consumer that matters is Vulkan. If the GL stack of +this driver is what breaks, then running the same ported code on the Vulkan +backend is both the cheapest test of hypothesis (B) — if the identical RHI world +path renders correctly on Vulkan on the same GPU, the defect is in the driver's +GL stack, conclusively and in one measurement instead of a multi-hour bisect — +and the shipping path. The sequencing cost is that V5/V6 must be written against +an RHI whose world-path consumer is proven only offline, and that V4c's diff sits +un-landed on a branch meanwhile; the sequencing invariants in §5.4 would need +V4c/V4d/V4t moved after V6, with V4a/V4b/V4e/V4f/V4g (all landed or independent) +unaffected. + +**Recommendation: (C), with (B) as its fallback.** (A) is the only option with no +bounded cost and the worst prior — five falsified mechanisms and two facts that +live below the API. (B) is probably the right *conclusion*, but adopting it now +means paying the no-fork penalty on the strength of an inference; one Vulkan +bring-up turns that inference into a measurement, and it is work the campaign has +to do regardless. If the RHI world path renders on Vulkan on this GPU, (B) is +proven and can be adopted deliberately, with the fork scoped and documented +rather than assumed. If it fails on Vulkan too, then the defect is ours after +all, the trigger is in code we own, and (A) becomes worth its cost because it +would then have a much smaller haystack. + +**Re-land conditions, updated.** §5.5's three conditions stand, with two +amendments: condition 2's gate now asserts on the desktop witness +(`tools/run-repeat-connected-gate.ps1` and `tools/run-blank-world-ab-probe.ps1` +grab the composited window and treat the client's own capture as a recorded +second column), and no re-land attempt should be made before the (C) measurement, +because a 10/10 pass on this machine cannot distinguish a fix from the defect's +ordinary ~1-in-5 quiet streak. ### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6 diff --git a/tools/run-blank-world-ab-probe.ps1 b/tools/run-blank-world-ab-probe.ps1 index e2d5af70..a314177f 100644 --- a/tools/run-blank-world-ab-probe.ps1 +++ b/tools/run-blank-world-ab-probe.ps1 @@ -17,9 +17,12 @@ exonerate the change under test; a rate that follows the binary implicates it. Each cycle is the repeat gate's cycle exactly: launch connected -> teleloc to - the pinned cell -> wait world-visible -> settle -> screenshot -> graceful close - -> cooldown. The verdict is the same screenshot-size test, so the two scripts' - numbers are directly comparable. + the pinned cell -> wait world-visible -> settle -> screenshot -> desktop grab + -> graceful close -> cooldown. The verdict is the same desktop-witness test, + so the two scripts' numbers are directly comparable - see that script's + description for why the client's own capture cannot be the verdict, and for + the "nothing else on the primary monitor, do not type" constraints that + grabbing the composited window imposes. .PARAMETER Pairs A/B pairs to run. Default 5 (ten launches). @@ -36,7 +39,8 @@ repeat gate; the failure rate is location-sensitive. .PARAMETER MinRenderedBytes - Screenshot size below which a run is called blank. Default 500000. + PNG size below which an image is called blank. Applied to the desktop grab + (the verdict) and to the client capture (recorded only). Default 500000. #> [CmdletBinding()] param( @@ -59,6 +63,20 @@ if (Get-Process -Name AcDream.App -ErrorAction SilentlyContinue) { throw 'AcDream.App is already running. This probe uses the shared test account and must not steal its session.' } +Add-Type -AssemblyName System.Drawing +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class AbProbeSurface { + [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; } +} +'@ + $root = Join-Path $env:TEMP "claude\blank-world-ab-$([DateTime]::Now.ToString('HHmmss'))" New-Item -ItemType Directory -Force -Path $root | Out-Null $results = @() @@ -77,7 +95,7 @@ wait materialized 1 90000 wait world-visible 30000 sleep 15000 screenshot ab-run 30000 -sleep 1000 +sleep 20000 "@ $env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call" @@ -91,15 +109,44 @@ sleep 1000 $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir $log = Join-Path $dir 'client.log' + # Normal window style: the frame must be composited on screen to be grabbed. $proc = Start-Process -FilePath $arm.Exe -RedirectStandardOutput $log ` - -RedirectStandardError "$log.err" -PassThru -WindowStyle Minimized + -RedirectStandardError "$log.err" -PassThru $shot = Join-Path $dir 'screenshots\ab-run.png' + $grab = Join-Path $dir 'desktop-grab.png' $deadline = (Get-Date).AddSeconds(180) while ((Get-Date) -lt $deadline) { - if (Test-Path $shot) { Start-Sleep -Seconds 2; break } + if (Test-Path $shot) { break } if ($proc.HasExited) { break } - Start-Sleep -Seconds 2 + Start-Sleep -Milliseconds 500 + } + + if (Test-Path $shot) { + Start-Sleep -Milliseconds 800 + try { + $proc.Refresh() + $h = $proc.MainWindowHandle + if ($h -ne [IntPtr]::Zero) { + [AbProbeSurface]::ShowWindow($h, 9) | Out-Null # SW_RESTORE + [AbProbeSurface]::SetForegroundWindow($h) | Out-Null + Start-Sleep -Milliseconds 1200 + $r = New-Object AbProbeSurface+RECT + [AbProbeSurface]::GetClientRect($h, [ref]$r) | Out-Null + $p = New-Object AbProbeSurface+POINT + [AbProbeSurface]::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() + } + } + } catch { + Write-Host "[ab-probe] desktop grab failed: $_" + } } $app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue @@ -108,15 +155,20 @@ sleep 1000 if (-not $app.WaitForExit(12000)) { $app | Stop-Process -Force } } - $size = if (Test-Path $shot) { (Get-Item $shot).Length } else { 0 } - $verdict = if ($size -ge $MinRenderedBytes) { 'RENDERED' } - elseif ($size -gt 0) { 'BLANK' } + $grabSize = if (Test-Path $grab) { (Get-Item $grab).Length } else { 0 } + $shotSize = if (Test-Path $shot) { (Get-Item $shot).Length } else { 0 } + $verdict = if ($grabSize -ge $MinRenderedBytes) { 'RENDERED' } + elseif ($grabSize -gt 0) { 'BLANK' } else { 'NO-CAPTURE' } + $clientView = if ($shotSize -ge $MinRenderedBytes) { 'RENDERED' } + elseif ($shotSize -gt 0) { 'BLANK' } + else { 'NO-CAPTURE' } $results += [pscustomobject]@{ - Pair = $pair; Arm = $arm.Label; Verdict = $verdict; Bytes = $size + Pair = $pair; Arm = $arm.Label; Verdict = $verdict; GrabBytes = $grabSize + ClientCapture = $clientView; ClientBytes = $shotSize } - Write-Host ("[ab-probe] pair {0}/{1} arm {2}: {3} ({4} bytes)" -f ` - $pair, $Pairs, $arm.Label, $verdict, $size) + Write-Host ("[ab-probe] pair {0}/{1} arm {2}: screen={3} ({4} B) client={5} ({6} B)" -f ` + $pair, $Pairs, $arm.Label, $verdict, $grabSize, $clientView, $shotSize) Start-Sleep -Seconds 10 # let ACE clear the graceful logout before the next login } diff --git a/tools/run-repeat-connected-gate.ps1 b/tools/run-repeat-connected-gate.ps1 index 41d4113f..015b470c 100644 --- a/tools/run-repeat-connected-gate.ps1 +++ b/tools/run-repeat-connected-gate.ps1 @@ -11,11 +11,30 @@ itself connected-clean on one lucky run. Each run: launch connected -> teleloc to the pinned worst-case cell -> - wait for world-visible -> settle -> screenshot -> graceful close -> cooldown. - The verdict is by screenshot content size: a rendered 1280x720 world compresses - to ~1.5 MB; the blank-world failure produces a few-KB flat PNG. The teleloc is - pinned because the failure rate is location-sensitive, and stray input into a - minimized window silently moves the character between runs. + wait for world-visible -> settle -> screenshot -> desktop grab -> graceful + close -> cooldown. The teleloc is pinned because the failure rate is + location-sensitive. + + THE VERDICT IS THE DESKTOP GRAB, not the client's own screenshot. Campaign + plan section 5.5.2 established that on a blank run the client's read of + framebuffer 0 returns RGBA(0,0,0,0) in every pixel INCLUDING pixels where the + UI is demonstrably on screen - so a verdict derived from screenshot bytes is + reporting the readback apparatus, not the renderer. This script therefore + launches the window visible, brings it to the foreground, and grabs the + composited client rectangle with CopyFromScreen: a witness that shares + nothing with the renderer below the compositor. The client's own capture is + still recorded, as a second column, because a disagreement between the two is + itself a finding. + + Observed magnitudes at 1280x720: a rendered desktop grab is ~1.6 MB, a blank + one (atmosphere clear plus the complete retained UI, no 3-D) is ~0.23 MB, and + a blank client capture is ~5 KB. One threshold separates all three. + + Run this with nothing else on the primary monitor: CopyFromScreen captures + whatever is composited at those coordinates, so an overlapping window would be + captured instead of the client. A visible foregrounded window can also take + stray input, which moves the character off the pinned cell - do not type + while the gate is running. Graceful close matters: a hard kill leaves ACE holding the session ~3 minutes and every subsequent run fails with 'CharacterList not received'. @@ -32,7 +51,8 @@ cell with the worst observed failure rate (5/5 blank at the V4c HEAD). .PARAMETER MinRenderedBytes - Screenshot size below which a run is called blank. Default 500000. + PNG size below which an image is called blank. Applied to both the desktop + grab (the verdict) and the client capture (recorded only). Default 500000. #> [CmdletBinding()] param( @@ -52,6 +72,20 @@ 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.' } +Add-Type -AssemblyName System.Drawing +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class RepeatGateSurface { + [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; } +} +'@ + $root = Join-Path $env:TEMP "claude\repeat-connected-$([DateTime]::Now.ToString('HHmmss'))" New-Item -ItemType Directory -Force -Path $root | Out-Null $results = @() @@ -66,7 +100,7 @@ wait materialized 1 90000 wait world-visible 30000 sleep 15000 screenshot repeat-run 30000 -sleep 1000 +sleep 20000 "@ $env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call" @@ -80,15 +114,46 @@ sleep 1000 $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir $log = Join-Path $dir 'client.log' + # Normal window style: the frame must be composited on screen to be grabbed. $proc = Start-Process -FilePath $ExePath -RedirectStandardOutput $log ` - -RedirectStandardError "$log.err" -PassThru -WindowStyle Minimized + -RedirectStandardError "$log.err" -PassThru $shot = Join-Path $dir 'screenshots\repeat-run.png' + $grab = Join-Path $dir 'desktop-grab.png' $deadline = (Get-Date).AddSeconds(180) while ((Get-Date) -lt $deadline) { - if (Test-Path $shot) { Start-Sleep -Seconds 2; break } + if (Test-Path $shot) { break } if ($proc.HasExited) { break } - Start-Sleep -Seconds 2 + Start-Sleep -Milliseconds 500 + } + + if (Test-Path $shot) { + # The client is now in its trailing sleep, still rendering this scene, + # so both images observe one steady frame. + Start-Sleep -Milliseconds 800 + try { + $proc.Refresh() + $h = $proc.MainWindowHandle + if ($h -ne [IntPtr]::Zero) { + [RepeatGateSurface]::ShowWindow($h, 9) | Out-Null # SW_RESTORE + [RepeatGateSurface]::SetForegroundWindow($h) | Out-Null + Start-Sleep -Milliseconds 1200 + $r = New-Object RepeatGateSurface+RECT + [RepeatGateSurface]::GetClientRect($h, [ref]$r) | Out-Null + $p = New-Object RepeatGateSurface+POINT + [RepeatGateSurface]::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() + } + } + } catch { + Write-Host "[repeat-gate] desktop grab failed: $_" + } } $app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue @@ -97,12 +162,20 @@ sleep 1000 if (-not $app.WaitForExit(12000)) { $app | Stop-Process -Force } } - $size = if (Test-Path $shot) { (Get-Item $shot).Length } else { 0 } - $verdict = if ($size -ge $MinRenderedBytes) { 'RENDERED' } - elseif ($size -gt 0) { 'BLANK' } + $grabSize = if (Test-Path $grab) { (Get-Item $grab).Length } else { 0 } + $shotSize = if (Test-Path $shot) { (Get-Item $shot).Length } else { 0 } + $verdict = if ($grabSize -ge $MinRenderedBytes) { 'RENDERED' } + elseif ($grabSize -gt 0) { 'BLANK' } else { 'NO-CAPTURE' } - $results += [pscustomobject]@{ Run = $i; Verdict = $verdict; Bytes = $size } - Write-Host ("[repeat-gate] run {0}/{1}: {2} ({3} bytes)" -f $i, $Runs, $verdict, $size) + $clientView = if ($shotSize -ge $MinRenderedBytes) { 'RENDERED' } + elseif ($shotSize -gt 0) { 'BLANK' } + else { 'NO-CAPTURE' } + $results += [pscustomobject]@{ + Run = $i; Verdict = $verdict; GrabBytes = $grabSize + ClientCapture = $clientView; ClientBytes = $shotSize + } + Write-Host ("[repeat-gate] run {0}/{1}: screen={2} ({3} B) client={4} ({5} B)" -f ` + $i, $Runs, $verdict, $grabSize, $clientView, $shotSize) Start-Sleep -Seconds 10 # let ACE clear the graceful logout before the next login } @@ -110,9 +183,14 @@ sleep 1000 Write-Host '' $blank = @($results | Where-Object Verdict -ne 'RENDERED') $results | Format-Table -AutoSize | Out-String | Write-Host +$split = @($results | Where-Object { $_.Verdict -ne $_.ClientCapture }) +if ($split.Count -gt 0) { + Write-Host ("[repeat-gate] note: {0}/{1} runs had the two instruments disagree." -f ` + $split.Count, $Runs) +} if ($blank.Count -gt 0) { - Write-Host ("[repeat-gate] FAILED: {0}/{1} runs did not render. Artifacts: {2}" -f $blank.Count, $Runs, $root) -ForegroundColor Red + Write-Host ("[repeat-gate] FAILED: {0}/{1} runs did not render on the desktop witness. Artifacts: {2}" -f $blank.Count, $Runs, $root) -ForegroundColor Red exit 1 } -Write-Host ("[repeat-gate] PASS: {0}/{0} runs rendered. Artifacts: {1}" -f $Runs, $root) +Write-Host ("[repeat-gate] PASS: {0}/{0} runs rendered on the desktop witness. Artifacts: {1}" -f $Runs, $root) exit 0