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)