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>
318 lines
13 KiB
C#
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;
|
|
}
|
|
}
|