feat(render): Campaign V slice V6h — the Vulkan composition host

ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather
than a second main(). All nine phases execute: DAT load, streaming, camera,
entity table, session, and the real retained UiHost drawing through the RHI.
No world renderers — they are raw GL until V4t and the world arm behind it.

The offline log is the client's own (acdream.pak opened, 6266 spells, Region
0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail
LayoutDesc lines, streaming radii), and the captured frame is the retail
retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot
toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled
against the GL capture the widgets agree — chat interior RGBA (25,24,27,158)
vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical.

Three seams, as §5.5.9 specified:

1. Platform acquisition — already generic — publishes GameWindowGraphics
   instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and
   take their Vulkan arm when it is null; each branch names the slice that
   removes it.
2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the
   Phase-1 fork: four graphics members differ, input/camera/pointer delegate.
   The default factory is chosen inside the phase from the platform result.
   HostInputCameraResult gained backend-neutral Retirement and FrameSlots.
3. The frame root forks on one condition. The GL world-scene assembly is
   unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one
   backbuffer clear pass computing the same RenderFrameFoundation from the same
   clock and weather owners, then private presentation over it.

§5.5.9's three TextureCache couplings are unpicked: the constructor takes GL?
and rejects bindless without one, world entry points route through a Gl
property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast
became a backend test. That cast's stated reason — DrawSprite's texture-unit
binding — was already stale, deleted at V6d.

VulkanBringUpHost is reduced to the capability-probe harness it is named for:
the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext,
which the composition host and the harness now share. It is reached only with
ACDREAM_VULKAN_PROBE=1.

One latent Vulkan defect surfaced and is fixed here. The first composition-host
frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 —
descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a
side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the
texture table while binding no buffer — every retained-UI and debug-line pass —
drew with the table unbound. It survived V6c-V6g because the bring-up host
always drew VulkanRhiScene first and the UI pass inherited its binds; the
composition host has no 3-D scene. The fix is one line in the encoder's
constructor beside the viewport and scissor defaults, which exist for exactly
the same reason: a pass opens with complete binding state rather than depending
on what preceded it.

Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of
563,200 pixels), inside the documented 15-23 px / 4.1e-05 band, so GL behaviour
did not move. App tests 4,075/3 skips; complete Release suite 9,138/5 skips.
One full Vulkan run with VK_LAYER_KHRONOS_validation: zero errors, zero
warnings. Both Vulkan runs converged the ownership ledger — no [shutdown]
diagnostic on either stream. The reduced probe harness presented 34,811
validation-clean frames.

No divergence-register row: GL is the shipping backend and the pixel gate proves
it unmoved; the Vulkan arm is not a retail deviation but a backend under
construction.

Next is V4t, the texture stack, which the world arm cannot be written without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 11:47:37 +02:00
parent 46d893f7c3
commit b16f820643
29 changed files with 2292 additions and 997 deletions

View file

@ -23,7 +23,7 @@ namespace AcDream.App.Rendering;
public sealed class GameWindow :
IDisposable,
IGameWindowPlatformPublication<GL, IInputContext>,
IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>,
IGameWindowHostInputCameraPublication,
IGameWindowContentEffectsAudioPublication,
IGameWindowSettingsDevToolsPublication,
@ -45,7 +45,16 @@ public sealed class GameWindow :
private readonly HostQuiescenceGate _hostQuiescence = new();
private IWindow? _window;
private SilkWindowCallbackBinding? _windowCallbacks;
private GL? _gl;
private GameWindowGraphics? _graphics;
// Campaign V slice V6h: borrowed, not owned — _graphics owns the context and
// disposes it. Retained because the render callback has to bring the
// swapchain up to date before a frame opens, which is the one piece of
// presentation the RHI contract deliberately does not cover.
private AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext? _vulkanGraphics;
private FramePacingPolicy _startupPacing;
private AcDream.UI.Abstractions.Settings.QualitySettings _startupQuality =
AcDream.UI.Abstractions.Settings.QualitySettings.From(
AcDream.UI.Abstractions.Settings.QualityPreset.High);
private IInputContext? _input;
private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
@ -414,7 +423,14 @@ public sealed class GameWindow :
// directly to it during composition.
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
// DevToolsEnabled reads through typed RuntimeOptions.
private bool DevToolsEnabled => _options.DevTools;
//
// Campaign V slice V6h: the developer frontend is ImGui, which is not ported
// to Vulkan and which slice V11 deletes outright, so a Vulkan host composes
// none regardless of ACDREAM_DEVTOOLS. The flag still reaches
// VulkanInstanceFactory, where it selects the optional debug-utils
// instance extensions.
private bool DevToolsEnabled =>
_options.DevTools && _options.RenderBackend != RenderBackendKind.Vulkan;
// Phase G.1-G.2 world lighting/time state. The environment owner keeps
// the clock, selected day group, and weather transitions coherent.
@ -680,44 +696,60 @@ public sealed class GameWindow :
// attribute, so it must come from this same snapshot rather than a
// second settings load during OnLoad.
RuntimeSettingsSnapshot startup = _runtimeSettings.Startup;
if (_options.RenderBackend == RenderBackendKind.Vulkan)
if (_options.RenderBackend == RenderBackendKind.Vulkan
&& _options.VulkanCapabilityProbe)
{
// Campaign V slice V5 — dark Vulkan bring-up. Reached only when
// ACDREAM_RENDER_BACKEND=vulkan; the GL path below executes not one
// new statement. The host owns its own window, device and swapchain,
// and its capability gate throws NotSupportedException into the same
// exit-code-4 contract Program.cs already publishes for GL.
using var vulkan = new AcDream.App.Rendering.Gpu.Vk.VulkanBringUpHost(
// Campaign V slice V6h reduced the V5 bring-up host to what its name
// says: a capability probe. It opens its own window, runs the gate,
// presents the synthetic V6c/V6d verification scenes, and exits —
// useful for answering "does this machine pass the Vulkan gate, and
// does the backend draw?" without the client. The production Vulkan
// path is the composition host below.
using var probe = new AcDream.App.Rendering.Gpu.Vk.VulkanBringUpHost(
_options,
_platformServices,
startup.Display.VSync);
vulkan.Run();
probe.Run();
return;
}
FramePacingPolicy startupPacing =
_displayFramePacing.InitializeStartup(startup.Display.VSync);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — phase 1",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = startupPacing.UseVSync,
// A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample).
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startup.Quality.MsaaSamples,
// #117 (2026-06-11): the aperture punch's depth gate needs a
// stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
// GLFW defaults to 8 stencil bits, but make the requirement
// explicit rather than platform-implicit.
PreferredStencilBufferBits = 8,
};
// Campaign V slice V6h: the ONLY startup difference between the two
// backends. Vulkan needs a client-API-less window (the surface comes from
// VK_KHR_surface), and neither MSAA nor the stencil bit count is a window
// attribute there — both are attachment properties the RHI device
// configures, so they are passed to VulkanGraphicsContext instead.
var options = _options.RenderBackend == RenderBackendKind.Vulkan
? WindowOptions.DefaultVulkan with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — Vulkan",
VSync = startupPacing.UseVSync,
}
: WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — phase 1",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = startupPacing.UseVSync,
// A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample).
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startup.Quality.MsaaSamples,
// #117 (2026-06-11): the aperture punch's depth gate needs a
// stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
// GLFW defaults to 8 stencil bits, but make the requirement
// explicit rather than platform-implicit.
PreferredStencilBufferBits = 8,
};
_startupPacing = startupPacing;
_startupQuality = startup.Quality;
_window = Window.Create(options);
_lifetime.PublishNativeWindow(_window);
@ -748,17 +780,21 @@ public sealed class GameWindow :
}
}
void IGameWindowPlatformPublication<GL, IInputContext>.PublishGraphics(
GL graphics) =>
PublishCompositionOwner(ref _gl, graphics, "graphics API");
void IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>.PublishGraphics(
GameWindowGraphics graphics) =>
PublishCompositionOwner(ref _graphics, graphics, "graphics API");
void IGameWindowPlatformPublication<GL, IInputContext>.PublishInput(
void IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>.PublishInput(
IInputContext input) =>
PublishCompositionOwner(ref _input, input, "input context");
void IGameWindowHostInputCameraPublication.PublishGpuFrameFlights(
GpuFrameFlightController value) =>
PublishCompositionOwner(ref _gpuFrameFlights, value, "GPU frame flights");
GpuFrameFlightController? value)
{
// Null on a backend whose RHI device owns its own frame flights.
if (value is not null)
PublishCompositionOwner(ref _gpuFrameFlights, value, "GPU frame flights");
}
void IGameWindowHostInputCameraPublication.PublishGpuDevice(
IGpuDevice value) =>
@ -1169,51 +1205,105 @@ public sealed class GameWindow :
destination = value;
}
[System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_gl), nameof(_input))]
private GameWindowPlatformResult<GL, IInputContext> AcquirePlatform()
[System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_graphics), nameof(_input))]
private GameWindowPlatformResult<GameWindowGraphics, IInputContext> AcquirePlatform()
{
GameWindowPlatformResult<GL, IInputContext> platform =
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform =
GameWindowPlatformAcquisition.Acquire(
() => GL.GetApi(_window!),
static gl => gl.Dispose(),
CreateGraphics,
static graphics => graphics.Dispose(),
() => _window!.CreateInput(),
static input => input.Dispose(),
this);
if (_gl is null || _input is null)
if (_graphics is null || _input is null)
throw new InvalidOperationException(
"Platform acquisition returned without publishing both owners.");
return platform;
}
/// <summary>
/// Campaign V slice V6h: how the UI probe reads a completed frame.
///
/// <para><see cref="FrameScreenshotController"/> applies the bottom-up flip
/// <c>glReadPixels</c> needs, so GL hands it the raw read and Vulkan — whose
/// <see cref="IGpuDevice.CaptureBackbuffer"/> is documented top-left-origin —
/// pre-flips so the two cancel. Routing GL through the same
/// <c>CaptureBackbuffer</c> seam would double-flip, which is exactly the kind
/// of "usually right" instrument §5.5 of the campaign plan spent three
/// slices removing.</para>
/// </summary>
private static Func<int, int, byte[]> CreateBackbufferReader(
GameWindowGraphics graphics,
IGpuDevice device) =>
graphics.Gl is { } gl
? (width, height) =>
AcDream.App.Diagnostics.FrameScreenshotController
.ReadDefaultFramebuffer(gl, width, height)
: (width, height) =>
AcDream.App.Diagnostics.FrameScreenshotController.FlipRows(
device.CaptureBackbuffer(width, height),
width,
height);
/// <summary>
/// Campaign V slice V6h: the backend fork, in one expression. Vulkan's
/// context acquisition runs its own capability gate inside
/// <see cref="AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext.Acquire"/>,
/// throwing <see cref="NotSupportedException"/> into the same exit-code-4
/// contract <c>Program.cs</c> publishes for GL.
/// </summary>
private GameWindowGraphics CreateGraphics()
{
if (_options.RenderBackend != RenderBackendKind.Vulkan)
return new OpenGlGameWindowGraphics(GL.GetApi(_window!));
AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext vulkan =
AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext.Acquire(
_window!,
_options,
_platformServices,
_startupPacing,
_startupQuality.MsaaSamples,
Console.WriteLine);
_vulkanGraphics = vulkan;
return new VulkanGameWindowGraphics(vulkan);
}
private void OnLoad()
{
// Task 7: wire the physics data cache into the engine so Transition can
// run narrow-phase BSP tests during FindObjCollisions.
GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();
string capabilityReportPath = Path.Combine(
_applicationPaths.DiagnosticsDirectory,
"graphical-capabilities.json");
_graphicalCapabilities =
GraphicalCapabilityGuard.CaptureVerifyAndWrite(
platform.Graphics,
_window!,
platform.Input,
_platformServices,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform = AcquirePlatform();
// The GL capability gate reads GL extension strings, so it runs only on
// the GL arm. Vulkan's equivalent gate already ran inside
// VulkanGraphicsContext.Acquire and wrote its own report.
if (platform.Graphics.Gl is { } capabilityGl)
{
string capabilityReportPath = Path.Combine(
_applicationPaths.DiagnosticsDirectory,
"graphical-capabilities.json");
_graphicalCapabilities =
GraphicalCapabilityGuard.CaptureVerifyAndWrite(
capabilityGl,
_window!,
platform.Input,
_platformServices,
capabilityReportPath);
GraphicalCapabilityGuard.ThrowIfUnsupported(
_graphicalCapabilities,
capabilityReportPath);
GraphicalCapabilityGuard.ThrowIfUnsupported(
_graphicalCapabilities,
capabilityReportPath);
Console.WriteLine(
"graphics: capability gate passed " +
$"({_graphicalCapabilities.ActiveDisplayProtocol}, " +
$"{_graphicalCapabilities.GlVendor}, " +
$"{_graphicalCapabilities.GlRenderer}, " +
$"{_graphicalCapabilities.GlVersion}); " +
$"report={capabilityReportPath}");
Console.WriteLine(
"graphics: capability gate passed " +
$"({_graphicalCapabilities.ActiveDisplayProtocol}, " +
$"{_graphicalCapabilities.GlVendor}, " +
$"{_graphicalCapabilities.GlRenderer}, " +
$"{_graphicalCapabilities.GlVersion}); " +
$"report={capabilityReportPath}");
}
GameWindowCompositionPipeline.Run<
GameWindowPlatformResult<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult,
@ -1322,7 +1412,12 @@ public sealed class GameWindow :
new WorldRenderDependencies(
_worldEnvironment,
_renderResourceLifetime,
_gpuFrameFlights!,
// Campaign V slice V6h: the device's own retirement queue
// rather than the GL frame-flight controller directly. On
// GL these are the same object (GlGpuDevice.Retirement
// returns the controller it was constructed with); on
// Vulkan the device owns its timeline-backed queue.
_gpuDevice!.Retirement,
_options.ResidencyBudgets,
initialCenterLandblockId,
_applicationPaths.DiagnosticsDirectory,
@ -1344,6 +1439,9 @@ public sealed class GameWindow :
new InteractionRetainedUiDependencies(
_options,
platformResult.Graphics,
CreateBackbufferReader(
platformResult.Graphics,
hostInputCamera.GpuDevice),
_window!,
platformResult.Input,
worldRender.Foundation.ShadersDirectory,
@ -1570,12 +1668,30 @@ public sealed class GameWindow :
private void OnRender(double deltaSeconds)
{
Vector2D<int> size = _window!.Size;
_frameGraphs.Render(
new AcDream.App.Rendering.RenderFrameInput(
deltaSeconds,
size.X,
size.Y),
out _);
// Campaign V slice V6h: swapchain currency is the one piece of
// presentation the RHI contract deliberately leaves to the host (plan
// §4.9). A minimised window has no swapchain to render into; an
// out-of-date one is recreated at a frame boundary, the only safe point.
// The handoff below stays exactly one call on both backends.
if (_vulkanGraphics is { } vulkan && !vulkan.PrepareFrame())
return;
try
{
_frameGraphs.Render(
new AcDream.App.Rendering.RenderFrameInput(
deltaSeconds,
size.X,
size.Y),
out _);
}
catch (AcDream.App.Rendering.Gpu.Vk.VulkanSwapchainOutOfDateException)
when (_vulkanGraphics is not null)
{
_vulkanGraphics.RequestRecreate();
return;
}
_vulkanGraphics?.NoteFrameClosed();
}
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
@ -1709,7 +1825,7 @@ public sealed class GameWindow :
_dats,
_preparedAssets,
_input,
_gl));
_graphics));
private void OnFocusChanged(bool focused)
=> _cameraPointerInput?.HandleFocusChanged(focused);