acdream/tools/run-blank-world-surface-probe.ps1
Erik fed636b9c0 test(render): add blank-world attribution apparatus and a post-world GL sample
Investigating the V4c connected blank-world failure needed two things the tree
did not have: a way to tell whether a blank run is caused by the binary under
test, and a way to see GL state at the end of the world phase rather than only
at the frame clear. Both are apparatus only - no production behaviour changes,
and the new probe emits nothing unless ACDREAM_PROBE_GLSTATE=1.

run-blank-world-ab-probe.ps1 interleaves two client builds over the repeat
gate's exact connected route and reports the blank rate per arm. This exists
because the blank rate is not stable across blocks: the same V4c binary
measured 3/10 in one block and 7/10 in another an hour later, so a block of A
followed by a block of B confounds the change with whatever else moved on the
machine in between. Strict alternation shares that drift between both arms.
Run against V4c and its parent it reported 4/5 versus 0/5 (Fisher exact
p~0.024), which is what established the defect follows the binary.

run-blank-world-surface-probe.ps1 grabs the composited window off the desktop
with CopyFromScreen at the same moment the client writes its own screenshot.
No instrument inside the GL context can separate "the renderer drew nothing"
from "the read did not return what the renderer drew", because both live on
the same side of the readback; an independent witness can. It is what showed
the two disagree - see below.

EmitPostWorldGlStateIfChanged is a second sample of the existing [gl-state]
snapshot, taken at the end of the normal-world phase. The existing tripwire
samples just after the clear phase's RestoreFrameDefaults, so it can only
observe state that survives from one frame into the next, and the draw
framebuffer is restored by no frame-global path. A binding established during
the world phase and put back before the next clear was therefore invisible to
it. Sampling at both ends brackets the phase.

What the apparatus established, recorded here rather than in the campaign doc
because no fix landed and the doc's re-land conditions are unchanged:

  * The world draw path is not what is missing from the frame. On a blank run
    the desktop grab shows the atmosphere clear over the whole viewport and the
    complete retained UI - chat, radar, toolbar, vitals - in their normal
    places, with every 3-D surface absent. Terrain and sky are still raw GL and
    V4c does not touch them, so whatever V4c disturbs is shared, not per-
    renderer.
  * The CPU issues the same work either way. With ACDREAM_PROBE_FLAP=1 the
    render signature is identical between blank and rendered runs: same
    RetailPViewInside branch, same resolved root, terrain drawn, 3,331 outdoor
    statics and 6 live dynamics dispatched.
  * Both GL-state samples read fbo=0, full 1280x720 viewport, scissor off and
    err=0x0, byte-identical between blank and rendered runs.
  * The client's own capture disagrees with the screen. glReadPixels returns
    uniformly RGBA(0,0,0,0) on a frame the desktop grab shows as fog plus UI.
    The 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, so the gate's blank-versus-
    rendered verdict rests on undefined behaviour in both directions.

Baseline App tests 3,864 passed / 3 skipped, unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 00:08:10 +02:00

177 lines
7.7 KiB
PowerShell

<#
.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)