acdream/src/AcDream.App/Composition/CompositionAcquisitionScope.cs
Erik b16f820643 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>
2026-07-28 11:47:37 +02:00

289 lines
8.9 KiB
C#

using System.Runtime.ExceptionServices;
using AcDream.App.Rendering;
namespace AcDream.App.Composition;
/// <summary>
/// Owns resources acquired inside one startup-composition phase until each
/// resource is published to its long-lived owner. Unpublished resources roll
/// back in reverse dependency order; successful cleanup never replays.
/// </summary>
internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup
{
internal enum EntryState
{
Owned,
Transferred,
Released,
}
internal sealed class Entry(string name, object resource, Action release)
{
public string Name { get; } = name;
public object Resource { get; } = resource;
public Action Release { get; } = release;
public EntryState State { get; set; } = EntryState.Owned;
}
private readonly List<Entry> _entries = [];
private bool _cleanupActive;
private bool _closed;
public bool IsCleanupComplete =>
_entries.All(static entry => entry.State is not EntryState.Owned);
public CompositionAcquisitionLease<T> Acquire<T>(
string name,
Func<T> factory,
Action<T> release)
where T : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(release);
EnsureAcceptingOwnership();
T resource = factory()
?? throw new InvalidOperationException(
$"Composition factory '{name}' returned null.");
return Own(name, resource, release);
}
/// <summary>
/// Campaign V slice V6h: acquires a resource a backend may legitimately not
/// have. A null factory result is an absent owner, not a failure — the
/// publication still runs so the long-lived shell records the same slot on
/// both backends — and nothing enters the rollback ledger.
/// </summary>
public CompositionAcquisitionOptionalLease<T> AcquireOptional<T>(
string name,
Func<T?> factory,
Action<T> release)
where T : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(release);
EnsureAcceptingOwnership();
T? resource = factory();
return resource is null
? new CompositionAcquisitionOptionalLease<T>(null)
: new CompositionAcquisitionOptionalLease<T>(
Own(name, resource, release));
}
public CompositionAcquisitionLease<T> Own<T>(
string name,
T resource,
Action<T> release)
where T : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(resource);
ArgumentNullException.ThrowIfNull(release);
EnsureAcceptingOwnership();
var entry = new Entry(name, resource, () => release(resource));
_entries.Add(entry);
return new CompositionAcquisitionLease<T>(
this,
entry,
resource);
}
/// <summary>
/// Closes a successful phase. Every acquisition must already have moved to
/// a typed long-lived owner or aggregate owner.
/// </summary>
public void Complete()
{
if (_cleanupActive)
throw new InvalidOperationException(
"Composition cleanup is currently active.");
if (_closed)
return;
if (!IsCleanupComplete)
{
string pending = string.Join(
", ",
_entries
.Where(static entry => entry.State == EntryState.Owned)
.Select(static entry => entry.Name));
throw new InvalidOperationException(
$"Composition phase completed with unpublished resources: {pending}.");
}
_closed = true;
}
/// <summary>
/// Rolls back the unpublished prefix and then rethrows the construction
/// failure. If cleanup itself fails, the returned exception retains this
/// exact scope as retry ownership.
/// </summary>
public void RollbackAndThrow(Exception constructionFailure)
{
ArgumentNullException.ThrowIfNull(constructionFailure);
_closed = true;
try
{
RetryCleanup();
}
catch (AggregateException cleanupFailure)
{
var failures = new List<Exception> { constructionFailure };
failures.AddRange(cleanupFailure.InnerExceptions);
throw new CompositionAcquisitionException(
"Startup composition failed and rollback remains incomplete.",
this,
failures);
}
ExceptionDispatchInfo.Capture(constructionFailure).Throw();
}
public void RetryCleanup()
{
if (_cleanupActive || IsCleanupComplete)
return;
_closed = true;
_cleanupActive = true;
List<Exception>? failures = null;
try
{
for (int i = _entries.Count - 1; i >= 0; i--)
{
Entry entry = _entries[i];
if (entry.State != EntryState.Owned)
continue;
try
{
entry.Release();
entry.State = EntryState.Released;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Composition cleanup operation '{entry.Name}' failed.",
failure));
}
}
}
finally
{
_cleanupActive = false;
}
if (failures is not null)
{
throw new AggregateException(
"Startup composition rollback remains incomplete.",
failures);
}
}
private void EnsureAcceptingOwnership()
{
if (_closed || _cleanupActive)
throw new InvalidOperationException(
"The composition acquisition scope is no longer accepting ownership.");
}
private void Transfer<T>(Entry entry, T expected)
where T : class
{
if (_closed || _cleanupActive)
throw new InvalidOperationException(
"The composition acquisition scope is no longer transferable.");
if (!ReferenceEquals(entry.Resource, expected))
throw new InvalidOperationException(
"The acquisition lease does not own the expected resource.");
if (entry.State != EntryState.Owned)
throw new InvalidOperationException(
$"Composition resource '{entry.Name}' has already been transferred.");
entry.State = EntryState.Transferred;
}
internal sealed class CompositionAcquisitionLease<T>
where T : class
{
private readonly CompositionAcquisitionScope _scope;
private readonly Entry _entry;
internal CompositionAcquisitionLease(
CompositionAcquisitionScope scope,
Entry entry,
T resource)
{
_scope = scope;
_entry = entry;
Resource = resource;
}
public T Resource { get; }
public T Transfer()
{
_scope.Transfer(_entry, Resource);
return Resource;
}
public T Publish(Action<T> publish)
{
ArgumentNullException.ThrowIfNull(publish);
publish(Resource);
return Transfer();
}
}
/// <summary>A lease over a resource the active backend may not own at all.</summary>
internal sealed class CompositionAcquisitionOptionalLease<T>(
CompositionAcquisitionLease<T>? inner)
where T : class
{
public T? Resource => inner?.Resource;
public T? Transfer() => inner?.Transfer();
public T? Publish(Action<T?> publish)
{
ArgumentNullException.ThrowIfNull(publish);
if (inner is null)
{
publish(null);
return null;
}
publish(inner.Resource);
return inner.Transfer();
}
}
}
/// <summary>
/// Construction failure whose incomplete rollback remains retryable by the
/// process lifetime cleanup ledger.
/// </summary>
internal sealed class CompositionAcquisitionException : AggregateException,
IRetryableResourceCleanup
{
private readonly IRetryableResourceCleanup _cleanup;
public CompositionAcquisitionException(
string message,
IRetryableResourceCleanup cleanup,
IEnumerable<Exception> failures)
: base(message, failures)
{
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
}
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
public void RetryCleanup() => _cleanup.RetryCleanup();
}