acdream/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
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

284 lines
9.8 KiB
C#

using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
internal readonly record struct WorldSceneDiagnosticOutcome(
int VisibleLandblocks,
int TotalLandblocks);
internal interface IWorldSceneDiagnostics
{
CameraCellResolution CameraCellResolution { get; }
void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition);
void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition);
WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
}
/// <summary>
/// Owns world-pass probes, collision wireframes, and the DebugVM facts derived
/// from one completed normal-world draw. None of these diagnostics influence
/// visibility or pass ordering.
/// </summary>
internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
{
private readonly WorldRenderDiagnostics _diagnostics;
private readonly IWorldScenePViewDiagnosticSource _pview;
private readonly IWorldSceneDebugStateSource _state;
private readonly DebugLineRenderer? _lines;
private readonly PhysicsEngine _physics;
private readonly ILocalPlayerModeSource _mode;
private readonly IRuntimeLocalPlayerControllerSource _player;
private readonly DebugVmRenderFactsPublisher _debugVm;
private readonly bool _debugVmConsumerActive;
private int _debugDrawLogCount;
public WorldSceneDiagnosticsController(
WorldRenderDiagnostics diagnostics,
IWorldScenePViewDiagnosticSource pview,
IWorldSceneDebugStateSource state,
DebugLineRenderer? lines,
PhysicsEngine physics,
ILocalPlayerModeSource mode,
IRuntimeLocalPlayerControllerSource player,
DebugVmRenderFactsPublisher debugVm,
bool debugVmConsumerActive)
{
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
_state = state ?? throw new ArgumentNullException(nameof(state));
_lines = lines;
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_player = player ?? throw new ArgumentNullException(nameof(player));
_debugVm = debugVm ?? throw new ArgumentNullException(nameof(debugVm));
_debugVmConsumerActive = debugVmConsumerActive;
}
public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution;
public void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition)
{
if (!RenderingDiagnostics.ProbePvInputEnabled)
return;
_diagnostics.EmitPViewInput(
enabled: true,
portalFrame,
viewProjection,
clipRoot.IsOutdoorNode,
cameraPosition,
playerPosition,
_pview.RawPlayerPositionOr(playerPosition),
_pview.PlayerYaw,
_pview.SampleTerrainZ(cameraPosition.X, cameraPosition.Y));
}
public void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition)
{
_diagnostics.EmitRenderSignatureIfChanged(
RenderingDiagnostics.ProbeFlapEnabled,
branch,
clipRoot,
viewerRoot,
playerRoot,
viewerCellId,
playerCellId,
playerIndoorGate,
cameraInsideCell,
renderSky,
drawSkyThisFrame,
terrainDrawn,
terrainClipMode,
skyDrawn,
depthClear,
outdoorSceneryDrawn,
outdoorPortalDrawn: false,
outdoorRootObjectCount: 0,
liveDynamicDrawnCount,
sceneParticles,
pviewResult?.PortalFrame,
pviewResult?.ClipAssembly,
pviewResult?.DrawableCells,
pviewResult?.DiagnosticPartition,
exteriorPortalFrame: null,
exteriorClipAssembly: null,
exteriorDrawableCells: null,
exteriorPartition: null,
cameraPosition,
playerPosition);
}
public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
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;
int total = bounds.Count;
for (int index = 0; index < bounds.Count; index++)
{
var entry = bounds[index];
if (FrustumCuller.IsAabbVisible(
camera.Frustum,
entry.AabbMin,
entry.AabbMax))
{
visible++;
}
}
// AllEntriesForDebug is a yield sequence. Do not even construct its
// iterator unless the developer UI is mounted and consumes the facts.
if (_debugVmConsumerActive)
{
Vector3 nearestOrigin =
_mode.IsPlayerMode && _player.Controller is { } controller
? controller.Position
: camera.Position;
_debugVm.PublishDebugVmFacts(
consumerActive: true,
visible,
total,
nearestOrigin,
_physics.ShadowObjects.AllEntriesForDebug());
}
return new WorldSceneDiagnosticOutcome(visible, total);
}
private void DrawCollisionWireframes(in WorldCameraFrame camera)
{
if (!_state.CollisionWireframesVisible || _lines is null)
return;
_lines.Begin();
int drawn = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
{
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
{
float height = shadow.CylHeight > 0f
? shadow.CylHeight
: shadow.Radius * 2f;
_lines.AddCylinder(
shadow.Position,
shadow.Radius,
height,
new Vector3(0f, 1f, 0f));
}
else
{
_lines.AddCylinder(
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
shadow.Radius,
shadow.Radius * 2f,
new Vector3(1f, 0.5f, 0f));
}
drawn++;
}
if (_mode.IsPlayerMode && _player.Controller is { } localPlayer)
{
Vector3 position = localPlayer.Position;
_lines.AddCylinder(
new Vector3(position.X, position.Y, position.Z),
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
1.8f,
new Vector3(1f, 0f, 0f));
LogNearbyCollisionObjects(position, drawn);
}
_lines.Flush(camera.Camera.View, camera.Projection);
}
private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn)
{
if (_debugDrawLogCount >= 5)
return;
Console.WriteLine(
$"debug frame {_debugDrawLogCount}: player=({playerPosition.X:F1},{playerPosition.Y:F1},{playerPosition.Z:F1}) drew={drawn} "
+ $"totalReg={_physics.ShadowObjects.TotalRegistered}");
int logged = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
{
float dx = shadow.Position.X - playerPosition.X;
float dy = shadow.Position.Y - playerPosition.Y;
float horizontalDistance = MathF.Sqrt(dx * dx + dy * dy);
if (horizontalDistance >= 10f)
continue;
Console.WriteLine(
$" near id=0x{shadow.EntityId:X8} type={shadow.CollisionType} "
+ $"pos=({shadow.Position.X:F1},{shadow.Position.Y:F1},{shadow.Position.Z:F1}) "
+ $"r={shadow.Radius:F2} h={shadow.CylHeight:F2} dh={horizontalDistance:F2}");
if (++logged >= 5)
break;
}
_debugDrawLogCount++;
}
}