diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
index 50cdb85d..81660c22 100644
--- a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
+++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
@@ -121,6 +121,9 @@ internal sealed class WorldRenderDiagnostics
private string? _lastGlStateSignature;
private long _glStateFrame;
private long _glStateStableFrames;
+ private string? _lastPostWorldGlStateSignature;
+ private long _postWorldGlStateFrame;
+ private long _postWorldGlStateStableFrames;
private string? _lastScissorSignature;
private long _scissorSequence;
private string? _lastOutStageSignature;
@@ -180,6 +183,41 @@ internal sealed class WorldRenderDiagnostics
_glStateStableFrames = 0;
}
+ ///
+ /// Second sample of the same snapshot, taken at the END of the normal-world
+ /// phase instead of at the frame clear.
+ ///
+ ///
+ /// samples immediately after the
+ /// clear phase has run RestoreFrameDefaults, so it can only observe
+ /// state that survives from one frame into the next. State that a world pass
+ /// establishes and something in private presentation puts back before the
+ /// next clear is invisible to it — including the draw framebuffer, which no
+ /// frame-global restore touches. Sampling here as well brackets the world
+ /// phase, so a binding that the world's geometry drew into but the retained
+ /// UI did not shows up as a difference between the two lines rather than as
+ /// no line at all.
+ ///
+ public void EmitPostWorldGlStateIfChanged(bool enabled)
+ {
+ if (!enabled)
+ return;
+
+ _postWorldGlStateFrame++;
+ string signature = FormatGlState(_gl.CaptureState());
+ if (signature == _lastPostWorldGlStateSignature)
+ {
+ _postWorldGlStateStableFrames++;
+ return;
+ }
+
+ _log.WriteLine(
+ $"[gl-state-postworld] frame={_postWorldGlStateFrame} "
+ + $"stable={_postWorldGlStateStableFrames} {signature}");
+ _lastPostWorldGlStateSignature = signature;
+ _postWorldGlStateStableFrames = 0;
+ }
+
public void EmitClipRouteScissorProbe(
bool enabled,
bool applied,
diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
index 6a6d31c2..e36f8a0c 100644
--- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
+++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
@@ -171,6 +171,11 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{
ArgumentNullException.ThrowIfNull(bounds);
+ // Brackets the normal-world phase against the clear-phase tripwire: any
+ // GL state the world's geometry drew under but the next frame's clear
+ // no longer sees shows up as a difference between the two lines.
+ _diagnostics.EmitPostWorldGlStateIfChanged(
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
DrawCollisionWireframes(in camera);
int visible = 0;
diff --git a/tools/run-blank-world-ab-probe.ps1 b/tools/run-blank-world-ab-probe.ps1
new file mode 100644
index 00000000..e2d5af70
--- /dev/null
+++ b/tools/run-blank-world-ab-probe.ps1
@@ -0,0 +1,132 @@
+<#
+.SYNOPSIS
+ Campaign V blank-world A/B attribution probe: interleaves two client builds
+ over the same connected route and reports the blank rate per build.
+
+.DESCRIPTION
+ tools/run-repeat-connected-gate.ps1 answers "does THIS build blank?" It cannot
+ answer "does this build blank BECAUSE of the change?", because the blank rate
+ drifts with machine state - desktop composition, foreground window, driver
+ residency - between one ten-run block and the next. A block of A followed by a
+ block of B therefore confounds the change with whatever else moved in those
+ twenty minutes, and that is precisely how a build gets blamed for an ambient
+ defect.
+
+ This script runs A and B strictly alternately in one block, so any drift is
+ shared by both arms, and prints a per-arm verdict table. Identical rates
+ 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.
+
+.PARAMETER Pairs
+ A/B pairs to run. Default 5 (ten launches).
+
+.PARAMETER ExeA / ExeB
+ The two client executables. Give each a stable staging directory - not the
+ build output, which the next `dotnet build` overwrites.
+
+.PARAMETER LabelA / LabelB
+ Names for the arms in the verdict table.
+
+.PARAMETER Teleloc
+ The pinned location, as the full /teleloc argument string. Same default as the
+ repeat gate; the failure rate is location-sensitive.
+
+.PARAMETER MinRenderedBytes
+ Screenshot size below which a run is called blank. Default 500000.
+#>
+[CmdletBinding()]
+param(
+ [int]$Pairs = 5,
+ [Parameter(Mandatory = $true)][string]$ExeA,
+ [Parameter(Mandatory = $true)][string]$ExeB,
+ [string]$LabelA = 'A',
+ [string]$LabelB = 'B',
+ [string]$Teleloc = "0x2E430012 57.895 42.116 16.802 1 0 0 0",
+ [int]$MinRenderedBytes = 500000
+)
+
+$ErrorActionPreference = 'Stop'
+foreach ($exe in @($ExeA, $ExeB)) {
+ if (-not (Test-Path $exe)) { throw "Client not found at $exe." }
+}
+
+# Never race a session someone is already playing: same account, same server.
+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.'
+}
+
+$root = Join-Path $env:TEMP "claude\blank-world-ab-$([DateTime]::Now.ToString('HHmmss'))"
+New-Item -ItemType Directory -Force -Path $root | Out-Null
+$results = @()
+
+for ($pair = 1; $pair -le $Pairs; $pair++) {
+ foreach ($arm in @(
+ @{ Label = $LabelA; Exe = $ExeA },
+ @{ Label = $LabelB; Exe = $ExeB })) {
+
+ $dir = Join-Path $root ("pair-{0}-{1}" -f $pair, $arm.Label)
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
+ Set-Content -Encoding utf8 (Join-Path $dir 'probe.txt') -Value @"
+wait world-ready 90000
+command /teleloc $Teleloc
+wait materialized 1 90000
+wait world-visible 30000
+sleep 15000
+screenshot ab-run 30000
+sleep 1000
+"@
+
+ $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_UI_PROBE_SCRIPT = Join-Path $dir 'probe.txt'
+ $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir
+
+ $log = Join-Path $dir 'client.log'
+ $proc = Start-Process -FilePath $arm.Exe -RedirectStandardOutput $log `
+ -RedirectStandardError "$log.err" -PassThru -WindowStyle Minimized
+
+ $shot = Join-Path $dir 'screenshots\ab-run.png'
+ $deadline = (Get-Date).AddSeconds(180)
+ while ((Get-Date) -lt $deadline) {
+ if (Test-Path $shot) { Start-Sleep -Seconds 2; break }
+ if ($proc.HasExited) { break }
+ Start-Sleep -Seconds 2
+ }
+
+ $app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
+ if ($app) {
+ $app.CloseMainWindow() | Out-Null
+ 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' }
+ else { 'NO-CAPTURE' }
+ $results += [pscustomobject]@{
+ Pair = $pair; Arm = $arm.Label; Verdict = $verdict; Bytes = $size
+ }
+ Write-Host ("[ab-probe] pair {0}/{1} arm {2}: {3} ({4} bytes)" -f `
+ $pair, $Pairs, $arm.Label, $verdict, $size)
+
+ Start-Sleep -Seconds 10 # let ACE clear the graceful logout before the next login
+ }
+}
+
+Write-Host ''
+$results | Format-Table -AutoSize | Out-String | Write-Host
+foreach ($label in @($LabelA, $LabelB)) {
+ $arm = @($results | Where-Object Arm -eq $label)
+ $bad = @($arm | Where-Object Verdict -ne 'RENDERED')
+ Write-Host ("[ab-probe] {0}: {1}/{2} blank" -f $label, $bad.Count, $arm.Count)
+}
+Write-Host ("[ab-probe] artifacts: {0}" -f $root)
diff --git a/tools/run-blank-world-surface-probe.ps1 b/tools/run-blank-world-surface-probe.ps1
new file mode 100644
index 00000000..1fecdfc6
--- /dev/null
+++ b/tools/run-blank-world-surface-probe.ps1
@@ -0,0 +1,177 @@
+<#
+.SYNOPSIS
+ Campaign V blank-world surface probe: compares the client's own
+ glReadPixels capture against an OS-level grab of the same frame on screen.
+
+.DESCRIPTION
+ The connected gate's verdict comes from the client reading its own default
+ framebuffer with glReadPixels. That is one hypothesis about the world being
+ blank; it is not evidence. A capture returning zeros is equally consistent
+ with "the renderer drew nothing" and with "the read did not return what the
+ renderer drew" - and no instrument INSIDE the GL context can tell those
+ apart, because both live on the same side of the readback.
+
+ So this probe takes an independent witness. It launches the client VISIBLE
+ (the repeat gate minimises it), brings it to the foreground, and grabs the
+ composited window rectangle off the desktop with CopyFromScreen at the same
+ moment the client writes its own screenshot. Two images of one frame, taken
+ through two paths that share nothing below the renderer:
+
+ * both blank -> the renderer really did draw nothing.
+ * GL blank,
+ screen fine -> the frame was drawn and presented; the READBACK is what
+ failed, and every verdict built on it is measuring the
+ capture apparatus rather than the renderer.
+
+ The client's default framebuffer is 4x multisampled (SampleBuffers=1,
+ Samples=4 in the capability report), and glReadPixels against a multisampled
+ read framebuffer is undefined per the GL spec - which is exactly the kind of
+ thing that returns real pixels most of the time and zeros the rest.
+
+ 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.
+
+.PARAMETER ExePath
+ Client executable. Defaults to this repo's Release output.
+
+.PARAMETER Runs
+ Cycles to run. Default 6.
+
+.PARAMETER Teleloc
+ The pinned location, as the full /teleloc argument string.
+
+.PARAMETER MinRenderedBytes
+ PNG size below which an image is called blank. Default 500000.
+#>
+[CmdletBinding()]
+param(
+ [string]$ExePath,
+ [int]$Runs = 6,
+ [string]$Teleloc = "0x2E430012 57.895 42.116 16.802 1 0 0 0",
+ [int]$MinRenderedBytes = 500000
+)
+
+$ErrorActionPreference = 'Stop'
+$repo = Split-Path -Parent $PSScriptRoot
+if (-not $ExePath) { $ExePath = Join-Path $repo 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' }
+if (-not (Test-Path $ExePath)) { throw "Client not found at $ExePath." }
+
+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 WinSurface {
+ [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 IsIconic(IntPtr hWnd);
+ [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-surface-$([DateTime]::Now.ToString('HHmmss'))"
+New-Item -ItemType Directory -Force -Path $root | Out-Null
+$results = @()
+
+for ($i = 1; $i -le $Runs; $i++) {
+ $dir = Join-Path $root "run-$i"
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
+ # A long trailing sleep keeps the client rendering the same scene while the
+ # desktop grab is taken, so both images observe one steady frame.
+ Set-Content -Encoding utf8 (Join-Path $dir 'probe.txt') -Value @"
+wait world-ready 90000
+command /teleloc $Teleloc
+wait materialized 1 90000
+wait world-visible 30000
+sleep 15000
+screenshot surface-run 30000
+sleep 20000
+"@
+
+ $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_UI_PROBE_SCRIPT = Join-Path $dir 'probe.txt'
+ $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
+
+ $shot = Join-Path $dir 'screenshots\surface-run.png'
+ $osShot = Join-Path $dir 'os-grab.png'
+ $deadline = (Get-Date).AddSeconds(180)
+ while ((Get-Date) -lt $deadline) {
+ if (Test-Path $shot) { break }
+ if ($proc.HasExited) { break }
+ Start-Sleep -Milliseconds 500
+ }
+
+ $iconic = $null
+ if (Test-Path $shot) {
+ # The client is now in its trailing sleep, still rendering this scene.
+ Start-Sleep -Milliseconds 800
+ try {
+ $proc.Refresh()
+ $h = $proc.MainWindowHandle
+ if ($h -ne [IntPtr]::Zero) {
+ $iconic = [WinSurface]::IsIconic($h)
+ [WinSurface]::ShowWindow($h, 9) | Out-Null # SW_RESTORE
+ [WinSurface]::SetForegroundWindow($h) | Out-Null
+ Start-Sleep -Milliseconds 1200
+ $r = New-Object WinSurface+RECT
+ [WinSurface]::GetClientRect($h, [ref]$r) | Out-Null
+ $p = New-Object WinSurface+POINT
+ [WinSurface]::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($osShot, [System.Drawing.Imaging.ImageFormat]::Png)
+ $g.Dispose(); $bmp.Dispose()
+ }
+ }
+ } catch {
+ Write-Host "[surface-probe] desktop grab failed: $_"
+ }
+ }
+
+ $app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
+ if ($app) {
+ $app.CloseMainWindow() | Out-Null
+ if (-not $app.WaitForExit(12000)) { $app | Stop-Process -Force }
+ }
+
+ $glSize = if (Test-Path $shot) { (Get-Item $shot).Length } else { 0 }
+ $osSize = if (Test-Path $osShot) { (Get-Item $osShot).Length } else { 0 }
+ $glVerdict = if ($glSize -ge $MinRenderedBytes) { 'RENDERED' } elseif ($glSize -gt 0) { 'BLANK' } else { 'NO-CAPTURE' }
+ $osVerdict = if ($osSize -ge $MinRenderedBytes) { 'RENDERED' } elseif ($osSize -gt 0) { 'BLANK' } else { 'NO-CAPTURE' }
+ $results += [pscustomobject]@{
+ Run = $i; GlCapture = $glVerdict; GlBytes = $glSize
+ ScreenGrab = $osVerdict; ScreenBytes = $osSize; WasIconic = $iconic
+ }
+ Write-Host ("[surface-probe] run {0}/{1}: gl={2} ({3} B) screen={4} ({5} B)" -f `
+ $i, $Runs, $glVerdict, $glSize, $osVerdict, $osSize)
+
+ Start-Sleep -Seconds 10
+}
+
+Write-Host ''
+$results | Format-Table -AutoSize | Out-String | Write-Host
+$split = @($results | Where-Object { $_.GlCapture -ne 'RENDERED' -and $_.ScreenGrab -eq 'RENDERED' })
+Write-Host ("[surface-probe] frames where the GL capture blanked but the screen showed the world: {0}/{1}" -f `
+ $split.Count, $results.Count)
+Write-Host ("[surface-probe] artifacts: {0}" -f $root)