acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
Erik e77dd7c413 docs: launch-options reference + the test that keeps it honest
The client reads 161 ACDREAM_* environment variables across 79 files. Only
about 25 were written down, and the audit found the documentation drifting
in both directions: CLAUDE.md still advertised ACDREAM_RUN_SKILL /
ACDREAM_JUMP_SKILL (deleted; skills are server-authoritative now, and the
jump fallback is 300, not the documented 200), while flags with real
side effects had no description at all.

docs/launch-options.md documents every one by lifecycle — production,
command line, measurement, automation, permanent diagnostics, temporary
probes, deprecated, retired — with a mandatory side-effects column. That
column is the point: #432 cost three days of taxed measurements because
ACDREAM_AUTOMATION_ARTIFACT_DIR reads like an output path and also builds
a per-frame diagnostics referee, and ACDREAM_STREAM_RADIUS silently
measures a streaming window production never uses. Rows now say so. Other
surprises the audit surfaced and recorded: ACDREAM_DUMP_SCENERY_Z swaps in
a duplicate scenery-placement path rather than only logging,
ACDREAM_PROBE_VIS silently also enables ACDREAM_PROBE_ENVCELL, and
ACDREAM_DUMP_ENTITY's id list doubles as an unrelated probe's watchlist.

LaunchOptionsDocumentationTests enforces it, because a hand-maintained list
of 161 flags is stale within a week: an undocumented flag fails, and so
does a documented row whose read site was deleted. It scans string literals
rather than GetEnvironmentVariable call shapes — the startup path reads
through an injected delegate, so a call-shaped pattern silently missed
ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. A third
test freezes per-file direct-read debt by exact count (20 files outside the
owner classes), so structure rules 4 and 5 can be paid down but not
regressed.

CLAUDE.md's 94-line env-var section becomes a 16-line pointer, and its
stale test-character paragraph is corrected.

Also fixed, all doc-vs-code mismatches the audit proved:
- RenderingDiagnostics.FrameProfEnabled described a GPU-query self-disable
  that Campaign V slice V11 deleted.
- Two comments named ACDREAM_RENDER_BACKEND as a live co-requisite; it died
  with the OpenGL backend.
- EnvCellRenderer.CollectCellAuditLines and its ACDREAM_A8_AUDIT doc: the
  method had no caller anywhere and its documented caller never existed.

Filed rather than fixed, to keep this a documentation change: #434 (the
DebugPanel/DebugVM surface is never constructed, so ~40 "runtime-toggleable"
comments are false and 35 env reads are unreachable) and #435 (17 temporary
probes outlived their closed investigations; 14 more name no owner).

Full hermetic suite 12,202 passed / 0 failed.

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

318 lines
13 KiB
C#

using AcDream.App.Diagnostics;
using AcDream.App.Platform;
using AcDream.App.Rendering;
using Silk.NET.Maths;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// The Vulkan capability-probe and bring-up harness. Reached when
/// <c>ACDREAM_VULKAN_PROBE=1</c>. (This previously also required
/// <c>ACDREAM_RENDER_BACKEND=vulkan</c>; that variable died with the OpenGL
/// backend at Campaign V and is read nowhere — Vulkan is the only backend.)
///
/// <para><b>What it is for.</b> Answering two questions without starting the
/// client: does this machine pass the Vulkan capability gate, and does the RHI
/// backend actually draw? It opens a window, acquires a
/// <see cref="VulkanGraphicsContext"/> — which runs the gate and writes the
/// report — and presents the V6c/V6d verification scenes until the window
/// closes. The scenes are deliberately synthetic and deliberately asymmetric:
/// the one thing a symmetric layout could never prove is that the
/// negative-viewport Y flip and the capture path agree.</para>
///
/// <para><b>What it is no longer.</b> Through slice V6g this was the whole
/// Vulkan path — a second <c>main()</c> beside <c>GameWindow</c>'s composition.
/// Slice V6h moved production Vulkan onto the real composition host and lifted
/// the instance/surface/device/swapchain sequence out into
/// <see cref="VulkanGraphicsContext"/>, which both now share. What remains here
/// is the harness role the class is named for.</para>
///
/// <para><b>Untested by the implementing slice.</b> Every line below needs a
/// window and a driver. The pure decisions it depends on are unit-tested beside
/// it; the remaining gate is manual.</para>
/// </summary>
internal sealed class VulkanBringUpHost : IDisposable
{
/// <summary>Two frames in flight, matching plan §4.8 and the GL flight controller.</summary>
internal const int FlightCount = 2;
/// <summary>
/// The bring-up clear colour, linear-encoded straight into a UNORM
/// swapchain. A deliberate deep blue: black would be indistinguishable from
/// an unpainted window, and magenta is reserved as the "unresolved texture
/// slot" sentinel everywhere else in this codebase.
/// </summary>
internal static readonly float[] ClearColor = [0.043f, 0.075f, 0.153f, 1f];
private const string ScreenshotName = "vulkan-bringup";
private readonly RuntimeOptions _options;
private readonly GraphicalHostPlatformServices _platform;
private readonly FramePacingPolicy _pacing;
private readonly Action<string> _log;
private IWindow? _window;
private VulkanGraphicsContext? _graphics;
private VulkanRhiScene? _scene;
private VulkanRetainedUiScene? _ui;
private ulong _frameSerial;
private bool _disposed;
internal VulkanBringUpHost(
RuntimeOptions options,
GraphicalHostPlatformServices platform,
bool requestedVSync,
Action<string>? log = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_platform = platform ?? throw new ArgumentNullException(nameof(platform));
_log = log ?? Console.WriteLine;
// Resolved from the same pure policy the GL path uses, so "VSync on means
// FIFO" is one decision expressed once. The monitor refresh is left
// unknown because the harness consumes only UseVSync: there is no
// software pacer here to feed a limit to.
_pacing = FramePacingPolicy.Resolve(
requestedVSync,
_options.UncappedRendering,
monitorRefreshHz: null);
}
/// <summary>The record the gate evaluated, available after <see cref="Run"/> starts.</summary>
internal VulkanCapabilityRecord? Capabilities => _graphics?.Capabilities;
/// <summary>
/// Open the window, pass the capability gate, and present the verification
/// scenes until the window closes. Throws <see cref="NotSupportedException"/>
/// when the gate rejects the device, which <c>Program.cs</c> turns into exit
/// code 4 exactly as it does for the GL gate.
/// </summary>
internal void Run()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CreateWindow();
_graphics = VulkanGraphicsContext.Acquire(
_window!,
_options,
_platform,
_pacing,
// Four samples where the device allows it, so the backbuffer pass
// really resolves rather than rendering straight into the swapchain
// image. Plan §4.10 records that the V7 differential must force MSAA
// off; this is not that gate, and a resolve path that is never
// exercised is a resolve path that does not work.
requestedSampleCount: 4,
_log);
CreateScenes();
Present();
}
private void CreateWindow()
{
var options = WindowOptions.DefaultVulkan with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — Vulkan capability probe",
VSync = _pacing.UseVSync,
};
_window = Window.Create(options);
_window.Initialize();
}
private void CreateScenes()
{
VulkanGraphicsContext graphics = _graphics!;
_scene = new VulkanRhiScene(graphics.Device, graphics.SampleCount);
// Campaign V slice V6d: the retained UI and the debug lines draw here
// through exactly the classes the client uses, with a generated widget
// tree. Slice V6h's composition host draws the client's own tree; this
// keeps the isolated, session-free version reproducible.
_ui = new VulkanRetainedUiScene(
graphics.Device,
VulkanGraphicsContext.ShaderSpirvDirectory());
_log(
"vulkan: retained UI up — TextRenderer and DebugLineRenderer on the Vulkan device" +
(_ui.HasFont ? string.Empty : " (no system font found; glyph draws are skipped)"));
}
/// <summary>
/// The frame loop: record the verification scenes through the RHI, present,
/// and capture one screenshot once the scene has settled.
///
/// <para>Campaign V slice V9: when <c>ACDREAM_VULKAN_PROBE_FRAMES</c> is
/// positive the loop retires itself after that many presented frames instead
/// of waiting for the window to close. CI has no one to close it. The budget
/// never cuts the capture short — the loop stays open until the screenshot
/// has been attempted — because an unattended run whose whole product is a
/// PNG must not be able to exit without producing one.</para>
/// </summary>
private void Present()
{
IWindow window = _window!;
VulkanGraphicsContext graphics = _graphics!;
VulkanGpuDevice device = graphics.Device;
VulkanRhiScene scene = _scene!;
FrameScreenshotController? screenshots = CreateScreenshotController();
bool screenshotRequested = false;
DateTimeOffset started = DateTimeOffset.UtcNow;
while (!window.IsClosing)
{
window.DoEvents();
if (window.IsClosing)
break;
if (!graphics.PrepareFrame())
{
// Minimised: idle without burning a core, and without pretending
// a zero-area swapchain can be created.
Thread.Sleep(16);
continue;
}
if (!device.TryBeginFrame(out IGpuFrame? frame) || frame is null)
{
graphics.RequestRecreate();
continue;
}
double elapsed = (DateTimeOffset.UtcNow - started).TotalSeconds;
using (frame)
{
scene.Render(frame, graphics.Width, graphics.Height, elapsed);
// After the 3-D scene, in its own single-sampled load/store pass
// against the backbuffer — the same shape the client's HUD phase
// has, and the reason the multisampled pass must resolve rather
// than store.
_ui?.Render(frame, graphics.Width, graphics.Height, elapsed);
}
_frameSerial = (ulong)frame.Serial;
graphics.NoteFrameClosed();
// Capture after a few frames so the timer pool has resolved and the
// ring has cycled through both flight slots at least once.
if (screenshots is not null && !screenshotRequested && _frameSerial >= 4)
{
screenshotRequested = true;
if (screenshots.TryRequest(ScreenshotName, out string error))
{
screenshots.CapturePending((int)graphics.Width, (int)graphics.Height);
ReportTimings(device);
}
else
{
_log($"vulkan: screenshot request rejected: {error}");
}
}
if (ShouldRetire(screenshots, screenshotRequested))
{
_log(
$"vulkan: frame budget of {_options.VulkanCapabilityProbeFrames} " +
"reached; closing the probe.");
break;
}
}
device.WaitIdle();
_log($"vulkan: presented {_frameSerial} RHI frame(s); shutting down.");
}
private bool ShouldRetire(
FrameScreenshotController? screenshots,
bool screenshotRequested) =>
ShouldRetire(
_options.VulkanCapabilityProbeFrames,
_frameSerial,
screenshots is not null,
screenshotRequested);
/// <summary>
/// Campaign V slice V9. The bounded-run decision, pure so it can be tested
/// without a window or a driver.
/// </summary>
/// <param name="frameBudget">
/// <c>ACDREAM_VULKAN_PROBE_FRAMES</c>. Zero or negative means the
/// interactive behaviour: run until the window closes.
/// </param>
/// <param name="presentedFrames">Frames presented so far.</param>
/// <param name="capturesScreenshot">
/// Whether an artifact directory was configured, and so whether this run owes
/// a PNG.
/// </param>
/// <param name="screenshotRequested">Whether that capture has been attempted.</param>
internal static bool ShouldRetire(
int frameBudget,
ulong presentedFrames,
bool capturesScreenshot,
bool screenshotRequested)
{
if (frameBudget <= 0)
return false;
if (presentedFrames < (ulong)frameBudget)
return false;
// A budget below the capture threshold would otherwise exit with an empty
// artifact directory and a green step, which is the one outcome an
// unattended render gate must never produce.
return !capturesScreenshot || screenshotRequested;
}
private void ReportTimings(VulkanGpuDevice device)
{
if (!device.Timers.IsSupported)
{
_log("vulkan: GPU timestamps are unsupported on this device");
return;
}
string offscreen = device.Timers.TryResolve("offscreen", out double offscreenMs)
? $"{offscreenMs:F3} ms"
: "pending";
string main = device.Timers.TryResolve("main", out double mainMs)
? $"{mainMs:F3} ms"
: "pending";
_log($"vulkan: GPU timer scopes — offscreen {offscreen}, main {main}");
}
private FrameScreenshotController? CreateScreenshotController()
{
if (string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory))
return null;
return new FrameScreenshotController(
// IGpuDevice.CaptureBackbuffer is documented top-left-origin and a
// Vulkan image already is; FrameScreenshotController flips what it
// receives because glReadPixels hands back bottom-up rows. Flipping
// here makes the two cancel, so the PNG is right-side-up.
(width, height) => FrameScreenshotController.FlipRows(
_graphics!.Device.CaptureBackbuffer(width, height),
width,
height),
_options.AutomationArtifactDirectory,
_log);
}
/// <summary>
/// Teardown in strict reverse-construction order. The scenes go before the
/// context: they own buffers, textures, render targets and pipelines whose
/// release routes through the device's retirement queue, so the device has to
/// still be alive to drain it.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_ui?.Dispose();
_ui = null;
_scene?.Dispose();
_scene = null;
_graphics?.Dispose();
_graphics = null;
_window?.Dispose();
_window = null;
}
}