feat(render): Phase A8 — full GL state probe + pool diagnostics (apparatus)

Defense-in-depth apparatus per the 2026-05-27 handoff's option-1 recommendation.
The audit-found pool aliasing bug (prior commit) is the primary fix; this probe
is the safety net for any unidentified residual issue when the visual gate runs.

EmitDrawOrderProbe now logs the full GL state at each step boundary of
RenderInsideOutAcdream — stencil test/func/ref/mask/op, depth func/mask, cull
face/mode, blend src/dst, color writemask, current VAO, current program. An
operator can read the log offline and compare line-by-line against WB's
expected state at VisibilityManager.cs:73-239. Any divergence pinpoints the
bug's GL-state shape; matching state confirms the issue is elsewhere
(instance data, mesh upload, etc.).

EmitEnvCellProbe now logs pool diagnostics — total pool size + snapshot's
PostPreparePoolIndex high-water mark. A spike in poolTotal across stationary-
camera frames, or a divergence between poolHwm and cell-count, signals
pool-management regression. The fix-the-bug-first principle keeps this probe
dormant by default; enable via ACDREAM_PROBE_VIS=1 only when investigating.

Heavy (~10 GL queries per step × 5-10 steps per frame), but gated.

86/86 App tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 19:11:00 +02:00
parent 9559726960
commit 375f9a7b9b
2 changed files with 74 additions and 5 deletions

View file

@ -10892,25 +10892,79 @@ public sealed class GameWindow : IDisposable
private int _phaseA8DrawOrderFrame = 0;
/// <summary>
/// Phase A8 apparatus (2026-05-28): per-step GL state assertion probe.
/// Logs the full relevant GL state at each step boundary of
/// <see cref="RenderInsideOutAcdream"/> so an operator can read the log
/// offline and compare against WB's expected state (lifted line-by-line
/// from <c>VisibilityManager.cs:73-239</c>).
/// <para>Heavy under <c>ACDREAM_PROBE_VIS=1</c> (~10 GL queries per call,
/// 5-10 calls per indoor frame), but only enabled when the operator
/// explicitly opts in. The fix-the-bug-first principle keeps this probe
/// dormant by default; turn it on only when the visual is broken and
/// you need evidence.</para>
/// </summary>
private void EmitDrawOrderProbe(int step, char sub)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return;
_gl!.GetInteger(Silk.NET.OpenGL.GLEnum.StencilTest, out int stOn);
_gl!.GetInteger(Silk.NET.OpenGL.GLEnum.DepthFunc, out int depthFn);
_gl!.GetBoolean(Silk.NET.OpenGL.GLEnum.DepthWritemask, out var depthMask);
var gl = _gl!;
// Single-int queries.
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilTest, out int stOn);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.DepthFunc, out int depthFn);
gl.GetBoolean(Silk.NET.OpenGL.GLEnum.DepthWritemask, out var depthMask);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFace, out int cullEnabled);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFaceMode, out int cullMode);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendSrc, out int blendSrc);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendDst, out int blendDst);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFunc, out int sFunc);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilRef, out int sRef);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilValueMask, out int sValMask);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilWritemask, out int sWriteMask);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFail, out int sFail);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilPassDepthFail, out int sPdFail);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilPassDepthPass, out int sPdPass);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.VertexArrayBinding, out int vao);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CurrentProgram, out int prog);
// ColorWritemask is a bool[4]. Silk.NET exposes it via the
// GetBoolean overload with a Span<bool> destination — but a tighter
// capture (just whether all four bits are on) is sufficient for the
// diagnostic. Query each component separately via the indexed form.
// GL spec: GL_COLOR_WRITEMASK returns 4 booleans in order R,G,B,A.
Span<bool> cwmask = stackalloc bool[4];
unsafe
{
fixed (bool* p = cwmask)
gl.GetBoolean(Silk.NET.OpenGL.GLEnum.ColorWritemask, p);
}
string subStr = sub == ' ' ? "" : sub.ToString();
Console.WriteLine(
$"[draworder] frame={_phaseA8DrawOrderFrame} step={step}{subStr} " +
$"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask}");
$"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask} " +
$"cull={(cullEnabled != 0 ? "on" : "off")}({(cullMode == (int)Silk.NET.OpenGL.GLEnum.Front ? "front" : cullMode == (int)Silk.NET.OpenGL.GLEnum.Back ? "back" : "f+b")}) " +
$"blend=0x{blendSrc:X}/0x{blendDst:X} " +
$"sFunc=0x{sFunc:X}:{sRef}:0x{sValMask:X} " +
$"sOp=0x{sFail:X}/0x{sPdFail:X}/0x{sPdPass:X} sMask=0x{sWriteMask:X} " +
$"cMask=({(cwmask[0] ? 'R' : '-')}{(cwmask[1] ? 'G' : '-')}{(cwmask[2] ? 'B' : '-')}{(cwmask[3] ? 'A' : '-')}) " +
$"vao={vao} prog={prog}");
}
private void EmitEnvCellProbe(int ourBldgs, int otherBldgs, int filterCnt)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeEnvCellEnabled) return;
var stats = _envCellRenderer?.Stats ?? default;
// Phase A8 apparatus (2026-05-28): also log pool stats. A spike in
// poolTotal or a divergence between hwm and ourBldgs cell counts
// signals pool-management regression — the bug class the audit
// caught. Hwm should track number-of-(cell,gfxObj)-pairs per visible
// landblock and stay roughly stable across stationary-camera frames.
var pool = _envCellRenderer?.GetPoolDiagnostics() ?? (0, 0);
Console.WriteLine(
$"[envcells] cells={stats.CellsRendered} tris={stats.TrianglesDrawn} " +
$"ourBldgs={ourBldgs} otherBldgs={otherBldgs} filterCnt={filterCnt}");
$"ourBldgs={ourBldgs} otherBldgs={otherBldgs} filterCnt={filterCnt} " +
$"poolTotal={pool.PoolTotal} poolHwm={pool.SnapshotPoolHwm}");
}
private void EmitStencilProbe(string op)

View file

@ -88,6 +88,21 @@ public sealed unsafe class EnvCellRenderer : IDisposable
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
private LastFrameStats _lastFrameStats;
/// <summary>
/// Diagnostic accessor for the [envcells] probe (Phase A8 apparatus 2026-05-28).
/// Returns (pool-list count total, snapshot's PostPreparePoolIndex high-water).
/// A divergence between expected and actual values would indicate a pool-
/// management regression — exactly the bug class the 2026-05-28 audit caught.
/// </summary>
public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics()
{
int poolTotal;
lock (_listPool) poolTotal = _listPool.Count;
int hwm;
lock (_renderLock) hwm = _activeSnapshot.PostPreparePoolIndex;
return (poolTotal, hwm);
}
// ---------------------------------------------------------------------------
// Constructor + Initialize
// ---------------------------------------------------------------------------