diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 78f0051e..ee1be0a3 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1055,6 +1055,14 @@ withdraw first, then owner leases and caches, then device backing stores. This k drivers from reading freed memory without adding a portal-specific purge or a visual-distance reduction. +Native Vulkan dependencies remain scoped to their real producers and consumers. +The acquired-image semaphore wait and first swapchain transition both use the +color-output stage; fixed-function depth/stencil resolve destinations use the +color-output writer masks rather than the ordinary depth-test masks; and each +device-buffer migration publishes prior transfer writes to its exact source +range before the migration read. Host-staged uploads remain covered by the one +batched trailing draw-visibility barrier rather than gaining per-copy barriers. + --- ## Per-Frame Update Order (current runtime) diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 01c25dcb..e5207576 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -721,7 +721,8 @@ Update immediately when a slice changes state. Chat is not the ledger. witness returned; lead independently passed Release0W0E/314 portableGPU and repeated all four discriminating mutations with byte-identical restoration. 477-lead-verification.md records proof; API/behavior review1 PASS/no findings, -production/lifetime review2 in progress; integration/native validation owed. +production/lifetime review2 PASS/no findings. Scratch4f4ea8a50 integrated with +this ledger, unchanged five reviewed blobs; fresh build/native validation next. #476 separately dispatched to James at6347e0f82; #480 returned eight files, agent92/92 tests, pending lead verification/reviews. No new client run. Lead verified #480's retail/paired-binary child publication and contracted the separate diff --git a/docs/research/2026-09-01-overhaul/477-lead-verification.md b/docs/research/2026-09-01-overhaul/477-lead-verification.md index 7f6d0724..191c55d6 100644 --- a/docs/research/2026-09-01-overhaul/477-lead-verification.md +++ b/docs/research/2026-09-01-overhaul/477-lead-verification.md @@ -57,5 +57,9 @@ F1 command-witness gap is closed by this proof; production unchanged from round0. Independent API/behavior lens1 by Wegener PASS, no actionable finding: actual native producer/consumer bindings, narrow masks, exact ranges, source hashes and four lead mutation outcomes checked. Rawls now performs sequential -production/lifetime lens2. Integration and native validation remain owed. -G4 remains FAIL. No intentional deviation or new register row. +production/lifetime lens2 also PASS, no actionable findings: real copy +consumer/retirement, queue clear, frame bindings, exact five-file scope and +hashes checked. Two independent reviews total, one prior test-only F1. +Lead committed exact scratch4f4ea8a50f563274ce0afc342cff070d3458c452 and +integrated its unchanged five blobs with this ledger. Native validation and +fresh integrated build remain next. G4 FAIL; no new intentional deviation. diff --git a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md index 6e232d31..e807e265 100644 --- a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md +++ b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md @@ -4722,7 +4722,8 @@ Lead independently repeated all four mutations, observed their intended FAILs, restored raw hashes and ran Release0W0E plus314/314 portableGPU tests. The lead's original F1 evidence gap is closed;477-lead-verification.md records exact results. Wegener API/behavior review1 PASS, no findings; Rawls performs -production/lifetime review2. Native graphical validation owed. No integration yet. +production/lifetime review2 also PASS/no findings. Reviewed scratch4f4ea8a50 +integrates unchanged five blobs with this ledger; native validation owed. #476 dispatched to James in fresh codex/s5-476-openai-impl at6347e0f8269e201340b0c7a6b826ad4cfc21f43e, using476-completed-frame-capture-contract.md. #477 scratch is frozen; root diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs index 3804644f..779fb929 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs @@ -1047,46 +1047,47 @@ internal sealed unsafe partial class VulkanGpuDevice bool first = !_backbufferRenderingReady; _backbufferRenderingReady = true; - var barrier = new ImageMemoryBarrier2 - { - SType = StructureType.ImageMemoryBarrier2, - SrcStageMask = first - ? PipelineStageFlags2.TopOfPipeBit - : PipelineStageFlags2.ColorAttachmentOutputBit, - SrcAccessMask = first - ? AccessFlags2.None - : AccessFlags2.ColorAttachmentWriteBit, - DstStageMask = PipelineStageFlags2.ColorAttachmentOutputBit, - DstAccessMask = first - ? AccessFlags2.ColorAttachmentWriteBit - : AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.ColorAttachmentReadBit, - // Undefined for the acquire — the contents are genuinely undefined - // and saying so lets the driver skip a decompress. A later pass in - // the same frame must NOT say Undefined: that would license - // discarding everything drawn so far. - OldLayout = first ? ImageLayout.Undefined : ImageLayout.ColorAttachmentOptimal, - NewLayout = ImageLayout.ColorAttachmentOptimal, - SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, - DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, - Image = image, - SubresourceRange = new ImageSubresourceRange - { - AspectMask = ImageAspectFlags.ColorBit, - BaseMipLevel = 0, - LevelCount = 1, - BaseArrayLayer = 0, - LayerCount = 1, - }, - }; - var dependency = new DependencyInfo - { - SType = StructureType.DependencyInfo, - ImageMemoryBarrierCount = 1, - PImageMemoryBarriers = &barrier, - }; - _vk.CmdPipelineBarrier2(commands, &dependency); + ImageMemoryBarrier2 barrier = CreateBackbufferRenderingBarrier(image, first); + SubmitImageBarrier(commands, barrier); } + internal static ImageMemoryBarrier2 CreateBackbufferRenderingBarrier( + Image image, + bool first) => new() + { + SType = StructureType.ImageMemoryBarrier2, + // The first image use is ordered by the acquire semaphore, whose wait + // is scoped to this same stage. TopOfPipe here would run before that + // wait and race the presentation engine's ownership/layout use. + SrcStageMask = first + ? AcquiredImageWaitStage + : PipelineStageFlags2.ColorAttachmentOutputBit, + SrcAccessMask = first + ? AccessFlags2.None + : AccessFlags2.ColorAttachmentWriteBit, + DstStageMask = PipelineStageFlags2.ColorAttachmentOutputBit, + DstAccessMask = first + ? AccessFlags2.ColorAttachmentWriteBit + : AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.ColorAttachmentReadBit, + // Undefined for the acquire — the contents are genuinely undefined + // and saying so lets the driver skip a decompress. A later pass in + // the same frame must NOT say Undefined: that would license + // discarding everything drawn so far. + OldLayout = first ? ImageLayout.Undefined : ImageLayout.ColorAttachmentOptimal, + NewLayout = ImageLayout.ColorAttachmentOptimal, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }, + }; + /// /// Campaign V slice V6g: moves the multisampled colour scratch into /// COLOR_ATTACHMENT_OPTIMAL before the pass that names it there. @@ -1173,31 +1174,19 @@ internal sealed unsafe partial class VulkanGpuDevice if (target.DepthAttachment is { } depth) { - TransitionImage( - commands, + SubmitImageBarrier(commands, CreateRenderTargetDepthEntryBarrier( depth.Image, - ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, depth.CurrentLayout, - ImageLayout.DepthStencilAttachmentOptimal, - PipelineStageFlags2.AllCommandsBit, - AccessFlags2.None, - PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, - AccessFlags2.DepthStencilAttachmentWriteBit); + fixedFunctionResolve: false)); depth.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal); } if (target.DepthResolve is { } depthResolve) { - TransitionImage( - commands, + SubmitImageBarrier(commands, CreateRenderTargetDepthEntryBarrier( depthResolve.Image, - ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, depthResolve.CurrentLayout, - ImageLayout.DepthStencilAttachmentOptimal, - PipelineStageFlags2.AllCommandsBit, - AccessFlags2.None, - PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, - AccessFlags2.DepthStencilAttachmentWriteBit); + fixedFunctionResolve: true)); depthResolve.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal); } } @@ -1268,20 +1257,58 @@ internal sealed unsafe partial class VulkanGpuDevice if (depthStored && target.Description.SampleableDepth && target.DepthResult is { } depth) { - TransitionImage( - commands, + SubmitImageBarrier(commands, CreateRenderTargetDepthSamplingBarrier( depth.Image, - ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, depth.CurrentLayout, - ImageLayout.DepthStencilReadOnlyOptimal, - PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, - AccessFlags2.DepthStencilAttachmentWriteBit, - PipelineStageFlags2.FragmentShaderBit, - AccessFlags2.ShaderReadBit); + fixedFunctionResolve: target.DepthResolve is not null)); depth.MarkLayout(ImageLayout.DepthStencilReadOnlyOptimal); } } + internal static ImageMemoryBarrier2 CreateRenderTargetDepthEntryBarrier( + Image image, + ImageLayout oldLayout, + bool fixedFunctionResolve) + { + PipelineStageFlags2 writerStage = fixedFunctionResolve + ? PipelineStageFlags2.ColorAttachmentOutputBit + : PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit; + AccessFlags2 writerAccess = fixedFunctionResolve + ? AccessFlags2.ColorAttachmentWriteBit + : AccessFlags2.DepthStencilAttachmentWriteBit; + return CreateImageBarrier( + image, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + oldLayout, + ImageLayout.DepthStencilAttachmentOptimal, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None, + writerStage, + writerAccess); + } + + internal static ImageMemoryBarrier2 CreateRenderTargetDepthSamplingBarrier( + Image image, + ImageLayout oldLayout, + bool fixedFunctionResolve) + { + PipelineStageFlags2 writerStage = fixedFunctionResolve + ? PipelineStageFlags2.ColorAttachmentOutputBit + : PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit; + AccessFlags2 writerAccess = fixedFunctionResolve + ? AccessFlags2.ColorAttachmentWriteBit + : AccessFlags2.DepthStencilAttachmentWriteBit; + return CreateImageBarrier( + image, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + oldLayout, + ImageLayout.DepthStencilReadOnlyOptimal, + writerStage, + writerAccess, + PipelineStageFlags2.FragmentShaderBit, + AccessFlags2.ShaderReadBit); + } + private void TransitionDirectionalDepthForRendering( CommandBuffer commands, VulkanDirectionalDepthTarget target, @@ -1347,27 +1374,53 @@ internal sealed unsafe partial class VulkanGpuDevice uint baseArrayLayer = 0, uint layerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers) { - var barrier = new ImageMemoryBarrier2 + SubmitImageBarrier(commands, CreateImageBarrier( + image, + aspect, + oldLayout, + newLayout, + sourceStage, + sourceAccess, + destinationStage, + destinationAccess, + baseArrayLayer, + layerCount)); + } + + private static ImageMemoryBarrier2 CreateImageBarrier( + Image image, + ImageAspectFlags aspect, + ImageLayout oldLayout, + ImageLayout newLayout, + PipelineStageFlags2 sourceStage, + AccessFlags2 sourceAccess, + PipelineStageFlags2 destinationStage, + AccessFlags2 destinationAccess, + uint baseArrayLayer = 0, + uint layerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers) => new() + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = sourceStage, + SrcAccessMask = sourceAccess, + DstStageMask = destinationStage, + DstAccessMask = destinationAccess, + OldLayout = oldLayout, + NewLayout = newLayout, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = new ImageSubresourceRange { - SType = StructureType.ImageMemoryBarrier2, - SrcStageMask = sourceStage, - SrcAccessMask = sourceAccess, - DstStageMask = destinationStage, - DstAccessMask = destinationAccess, - OldLayout = oldLayout, - NewLayout = newLayout, - SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, - DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, - Image = image, - SubresourceRange = new ImageSubresourceRange - { - AspectMask = aspect, - BaseMipLevel = 0, - LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels, - BaseArrayLayer = baseArrayLayer, - LayerCount = layerCount, - }, - }; + AspectMask = aspect, + BaseMipLevel = 0, + LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels, + BaseArrayLayer = baseArrayLayer, + LayerCount = layerCount, + }, + }; + + private void SubmitImageBarrier(CommandBuffer commands, ImageMemoryBarrier2 barrier) + { var dependency = new DependencyInfo { SType = StructureType.DependencyInfo, diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs index b2556e49..4db562ba 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs @@ -72,6 +72,14 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineF /// Per-flight-slot ring capacity, matching the GL backend's 16 MiB. internal const int DefaultRingCapacityBytesPerSlot = 16 * 1024 * 1024; + /// + /// The acquired-image semaphore and the first swapchain-image transition + /// form one execution dependency. Keep the stage in one place so the + /// submit cannot drift from the barrier that consumes the acquired image. + /// + internal const PipelineStageFlags2 AcquiredImageWaitStage = + PipelineStageFlags2.ColorAttachmentOutputBit; + private readonly Silk.NET.Vulkan.Vk _vk; private readonly PhysicalDevice _physicalDevice; private readonly Device _device; @@ -447,12 +455,7 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineF SType = StructureType.CommandBufferSubmitInfo, CommandBuffer = commands, }; - var waitSemaphore = new SemaphoreSubmitInfo - { - SType = StructureType.SemaphoreSubmitInfo, - Semaphore = _imageAcquired[slot], - StageMask = PipelineStageFlags2.ColorAttachmentOutputBit, - }; + SemaphoreSubmitInfo waitSemaphore = CreateAcquiredImageWait(_imageAcquired[slot]); SemaphoreSubmitInfo* signals = stackalloc SemaphoreSubmitInfo[2]; int signalCount = 0; if (_acquiredImageIndex is { } presented && _backbuffer is not null) @@ -497,6 +500,13 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineF } } + internal static SemaphoreSubmitInfo CreateAcquiredImageWait(Semaphore semaphore) => new() + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = semaphore, + StageMask = AcquiredImageWaitStage, + }; + /// False after a present that reported the swapchain should be rebuilt. internal bool PresentSucceeded { get; private set; } = true; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs index 93dbe6c9..dba3216e 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs @@ -16,10 +16,12 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// direct analogue of the GL backend's "flush immediately before every draw" /// discipline, moved to the coarser granularity Vulkan actually needs. /// -/// One batched barrier, not one per copy. The drain emits a single -/// buffer memory barrier covering every copy it recorded, moving the whole batch -/// from transfer writes to the vertex/index/indirect/shader reads that follow. -/// Plan §4.8 budgets four to six barriers per frame; this is one of them. +/// One batched consumer barrier, not one per staging copy. The drain +/// emits a single buffer memory barrier covering every copy it recorded, moving +/// the whole batch from transfer writes to the vertex/index/indirect/shader reads +/// that follow. A device-buffer migration additionally needs one narrow source- +/// range transfer-write to transfer-read dependency before its copy; ordinary +/// host-staged writes do not. Plan §4.8 budgets the shared trailing barrier. /// /// Staging exhaustion is not an error. When the ring cannot serve a /// request — the payload is larger than the whole ring, or unretired frames hold @@ -50,7 +52,19 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable private bool _disposed; - private readonly record struct BufferCopy2(Buffer Source, Buffer Destination, ulong SourceOffset, ulong DestinationOffset, ulong SizeBytes); + internal enum BufferCopyKind + { + HostStaging, + DeviceMigration, + } + + private readonly record struct BufferCopy2( + Buffer Source, + Buffer Destination, + ulong SourceOffset, + ulong DestinationOffset, + ulong SizeBytes, + BufferCopyKind Kind); private readonly record struct ImageCopy2( Buffer Source, @@ -130,7 +144,8 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable destination, sourceOffset, destinationOffsetBytes, - (ulong)data.Length)); + (ulong)data.Length, + BufferCopyKind.HostStaging)); } /// Stages and queues a copy into one mip level of one array layer. @@ -207,7 +222,8 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable destination, sourceOffsetBytes, destinationOffsetBytes, - byteCount)); + byteCount, + BufferCopyKind.DeviceMigration)); } /// @@ -225,6 +241,21 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable foreach (BufferCopy2 copy in _bufferCopies) { + if (RequiresDeviceMigrationReadBarrier(copy.Kind)) + { + BufferMemoryBarrier2 barrier = CreateDeviceMigrationReadBarrier( + copy.Source, + copy.SourceOffset, + copy.SizeBytes); + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + BufferMemoryBarrierCount = 1, + PBufferMemoryBarriers = &barrier, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + var region = new BufferCopy { SrcOffset = copy.SourceOffset, @@ -301,6 +332,38 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable return true; } + /// + /// Makes earlier transfer writes to a device-buffer migration's exact + /// source range visible to its transfer read. This is emitted for each + /// migration—not for host-staged uploads—so same-drain A->B->C chains + /// and producers from earlier submissions share the same narrow rule. + /// + internal static BufferMemoryBarrier2 CreateDeviceMigrationReadBarrier( + Buffer source, + ulong sourceOffsetBytes, + ulong byteCount) + { + if (byteCount == 0) + throw new ArgumentOutOfRangeException(nameof(byteCount)); + + return new BufferMemoryBarrier2 + { + SType = StructureType.BufferMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.AllTransferBit, + SrcAccessMask = AccessFlags2.TransferWriteBit, + DstStageMask = PipelineStageFlags2.AllTransferBit, + DstAccessMask = AccessFlags2.TransferReadBit, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Buffer = source, + Offset = sourceOffsetBytes, + Size = byteCount, + }; + } + + internal static bool RequiresDeviceMigrationReadBarrier(BufferCopyKind kind) => + kind == BufferCopyKind.DeviceMigration; + private static ImageSubresourceRange WholeColorImage => new() { AspectMask = ImageAspectFlags.ColorBit, diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSynchronizationDependencyTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSynchronizationDependencyTests.cs new file mode 100644 index 00000000..0070d589 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSynchronizationDependencyTests.cs @@ -0,0 +1,642 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.Core.Contexts; +using Silk.NET.Vulkan; +using VkBuffer = Silk.NET.Vulkan.Buffer; +using VkImage = Silk.NET.Vulkan.Image; +using VkSemaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed class VulkanSynchronizationDependencyTests +{ + private const PipelineStageFlags2 DepthStages = + PipelineStageFlags2.EarlyFragmentTestsBit | + PipelineStageFlags2.LateFragmentTestsBit; + + [Fact] + public void AcquiredImage_FirstTransitionChainsToTheActualSemaphoreWait() + { + var image = new VkImage(0x4771u); + var semaphore = new VkSemaphore(0x4772u); + + SemaphoreSubmitInfo wait = VulkanGpuDevice.CreateAcquiredImageWait(semaphore); + ImageMemoryBarrier2 barrier = + VulkanGpuDevice.CreateBackbufferRenderingBarrier(image, first: true); + + Assert.Equal(StructureType.SemaphoreSubmitInfo, wait.SType); + Assert.Equal(semaphore, wait.Semaphore); + Assert.Equal(VulkanGpuDevice.AcquiredImageWaitStage, wait.StageMask); + Assert.Equal(wait.StageMask, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.None, barrier.SrcAccessMask); + Assert.Equal(PipelineStageFlags2.ColorAttachmentOutputBit, barrier.DstStageMask); + Assert.Equal(AccessFlags2.ColorAttachmentWriteBit, barrier.DstAccessMask); + Assert.Equal(ImageLayout.Undefined, barrier.OldLayout); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, barrier.NewLayout); + Assert.Equal(image, barrier.Image); + AssertColorImage(barrier.SubresourceRange); + } + + [Fact] + public void AcquiredImage_SubsequentPassPreservesPriorColorWrites() + { + var image = new VkImage(0x4773u); + + ImageMemoryBarrier2 barrier = + VulkanGpuDevice.CreateBackbufferRenderingBarrier(image, first: false); + + Assert.Equal(PipelineStageFlags2.ColorAttachmentOutputBit, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.ColorAttachmentWriteBit, barrier.SrcAccessMask); + Assert.Equal(PipelineStageFlags2.ColorAttachmentOutputBit, barrier.DstStageMask); + Assert.Equal( + AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.ColorAttachmentReadBit, + barrier.DstAccessMask); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, barrier.OldLayout); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, barrier.NewLayout); + Assert.Equal(image, barrier.Image); + AssertColorImage(barrier.SubresourceRange); + } + + [Fact] + public void BackbufferBarrier_IsBoundToOrdinaryAndFilmicProductionPasses() + { + string resources = Source("src", "AcDream.App", "Rendering", "Gpu", "Vk", + "VulkanGpuDevice.Resources.cs"); + string frame = Source("src", "AcDream.App", "Rendering", "Gpu", "Vk", + "VulkanGpuFrame.cs"); + string ordinary = Source("src", "AcDream.App", "Rendering", "Gpu", "Vk", + "VulkanCompositionFramePhases.cs"); + string filmic = Source("src", "AcDream.App", "Rendering", "Packs", + "AtmosphericPostProcessGraph.cs"); + + Assert.Contains( + "ImageMemoryBarrier2 barrier = CreateBackbufferRenderingBarrier(image, first);", + resources, + StringComparison.Ordinal); + string device = Source("src", "AcDream.App", "Rendering", "Gpu", "Vk", + "VulkanGpuDevice.cs"); + Assert.Contains( + "SemaphoreSubmitInfo waitSemaphore = CreateAcquiredImageWait(_imageAcquired[slot]);", + device, + StringComparison.Ordinal); + Assert.Contains("_device.BeginPass(this, description)", frame, StringComparison.Ordinal); + Assert.Contains("Name = \"vk-world\"", ordinary, StringComparison.Ordinal); + Assert.Contains("Target: null", ordinary, StringComparison.Ordinal); + Assert.Contains("\"atmospheric-filmic\"", filmic, StringComparison.Ordinal); + Assert.Contains("target: null", filmic, StringComparison.Ordinal); + } + + [Fact] + public void DepthTransitions_DistinguishSingleSampleSampleableDepth() + { + var image = new VkImage(0x4774u); + ImageMemoryBarrier2 entry = VulkanGpuDevice.CreateRenderTargetDepthEntryBarrier( + image, + ImageLayout.Undefined, + fixedFunctionResolve: false); + ImageMemoryBarrier2 exit = VulkanGpuDevice.CreateRenderTargetDepthSamplingBarrier( + image, + ImageLayout.DepthStencilAttachmentOptimal, + fixedFunctionResolve: false); + + AssertDepthEntry(entry, image, DepthStages, AccessFlags2.DepthStencilAttachmentWriteBit); + AssertDepthExit(exit, image, DepthStages, AccessFlags2.DepthStencilAttachmentWriteBit); + } + + [Fact] + public void DepthTransitions_MultisampleNonSampleableDepthKeepsOrdinaryWriterMasks() + { + var image = new VkImage(0x4775u); + ImageMemoryBarrier2 entry = VulkanGpuDevice.CreateRenderTargetDepthEntryBarrier( + image, + ImageLayout.Undefined, + fixedFunctionResolve: false); + + AssertDepthEntry(entry, image, DepthStages, AccessFlags2.DepthStencilAttachmentWriteBit); + } + + [Fact] + public void DepthTransitions_SampleableMsaaResolveUsesColorWriterAtEntryAndExit() + { + var image = new VkImage(0x4776u); + ImageMemoryBarrier2 entry = VulkanGpuDevice.CreateRenderTargetDepthEntryBarrier( + image, + ImageLayout.Undefined, + fixedFunctionResolve: true); + ImageMemoryBarrier2 exit = VulkanGpuDevice.CreateRenderTargetDepthSamplingBarrier( + image, + ImageLayout.DepthStencilAttachmentOptimal, + fixedFunctionResolve: true); + + AssertDepthEntry( + entry, + image, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit); + AssertDepthExit( + exit, + image, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit); + } + + [Fact] + public void DepthBarrierFactories_AreBoundToTheActualAttachmentAndResolveBranches() + { + string source = Source("src", "AcDream.App", "Rendering", "Gpu", "Vk", + "VulkanGpuDevice.Resources.cs"); + + Assert.Contains( + "depth.CurrentLayout,\n fixedFunctionResolve: false));", + Normalize(source), + StringComparison.Ordinal); + Assert.Contains( + "depthResolve.CurrentLayout,\n fixedFunctionResolve: true));", + Normalize(source), + StringComparison.Ordinal); + Assert.Contains( + "fixedFunctionResolve: target.DepthResolve is not null));", + Normalize(source), + StringComparison.Ordinal); + Assert.Contains( + "depthAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit;", + source, + StringComparison.Ordinal); + Assert.Contains( + "stencilAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit;", + source, + StringComparison.Ordinal); + + string target = Source("src", "AcDream.App", "Rendering", "Gpu", "Vk", + "VulkanGpuRenderTarget.cs"); + Assert.Contains( + "int retainedDepthSamples =\n description.SampleableDepth ? 1 : description.SampleCount;", + Normalize(target), + StringComparison.Ordinal); + Assert.Contains( + "Description.SampleableDepth && _multisampleDepth is not null ? _depth : null", + target, + StringComparison.Ordinal); + } + + [Fact] + public void DeviceMigrationBarrier_CoversTheExactSourceRange() + { + var source = new VkBuffer(0x4777u); + BufferMemoryBarrier2 barrier = VulkanUploadQueue.CreateDeviceMigrationReadBarrier( + source, + sourceOffsetBytes: 4096, + byteCount: 8192); + + Assert.Equal(StructureType.BufferMemoryBarrier2, barrier.SType); + Assert.Equal(PipelineStageFlags2.AllTransferBit, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.TransferWriteBit, barrier.SrcAccessMask); + Assert.Equal(PipelineStageFlags2.AllTransferBit, barrier.DstStageMask); + Assert.Equal(AccessFlags2.TransferReadBit, barrier.DstAccessMask); + Assert.Equal(source, barrier.Buffer); + Assert.Equal(4096ul, barrier.Offset); + Assert.Equal(8192ul, barrier.Size); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.SrcQueueFamilyIndex); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.DstQueueFamilyIndex); + } + + [Fact] + public void DeviceMigrationBarrier_RejectsAnEmptyRange() + { + Assert.Throws(() => + VulkanUploadQueue.CreateDeviceMigrationReadBarrier(default, 0, 0)); + } + + [Fact] + public void UploadQueue_ActualNativeRecord_StagesThenMigratesWithExactDependencyAndOneConsumerBarrier() + { + using var native = new RecordingNativeContext(); + var queue = CreateCommandWitnessQueue(native); + var commands = new CommandBuffer((nint)0xC04771u); + var staging = new VkBuffer(0xA04771u); + var a = new VkBuffer(0xA04772u); + var b = new VkBuffer(0xA04773u); + + EnqueueHostStagingCopy(queue, staging, a, sourceOffset: 16, destinationOffset: 32, size: 64); + queue.EnqueueBufferCopy(a, b, sourceOffsetBytes: 48, destinationOffsetBytes: 96, byteCount: 128); + + Assert.True(queue.Record(commands)); + Assert.Collection( + native.Commands, + command => AssertCopy(command, commands, staging, a, 16, 32, 64), + command => AssertMigrationBarrier(command, commands, a, 48, 128), + command => AssertCopy(command, commands, a, b, 48, 96, 128), + command => AssertTrailingConsumerBarrier(command, commands)); + + int emitted = native.Commands.Count; + Assert.False(queue.Record(commands)); + Assert.Equal(emitted, native.Commands.Count); + } + + [Fact] + public void UploadQueue_ActualNativeRecord_EmitsEachMigrationDependencyInAnAToBToCChain() + { + using var native = new RecordingNativeContext(); + var queue = CreateCommandWitnessQueue(native); + var commands = new CommandBuffer((nint)0xC04772u); + var a = new VkBuffer(0xB04771u); + var b = new VkBuffer(0xB04772u); + var c = new VkBuffer(0xB04773u); + + queue.EnqueueBufferCopy(a, b, sourceOffsetBytes: 64, destinationOffsetBytes: 80, byteCount: 144); + queue.EnqueueBufferCopy(b, c, sourceOffsetBytes: 96, destinationOffsetBytes: 112, byteCount: 288); + + Assert.True(queue.Record(commands)); + Assert.Collection( + native.Commands, + command => AssertMigrationBarrier(command, commands, a, 64, 144), + command => AssertCopy(command, commands, a, b, 64, 80, 144), + command => AssertMigrationBarrier(command, commands, b, 96, 288), + command => AssertCopy(command, commands, b, c, 96, 112, 288), + command => AssertTrailingConsumerBarrier(command, commands)); + } + + [Fact] + public void UploadQueue_ActualNativeRecord_EmitsMigrationDependencyForAnEarlierDrainProducer() + { + using var native = new RecordingNativeContext(); + var queue = CreateCommandWitnessQueue(native); + var commands = new CommandBuffer((nint)0xC04773u); + var staging = new VkBuffer(0xD04771u); + var a = new VkBuffer(0xD04772u); + var b = new VkBuffer(0xD04773u); + + EnqueueHostStagingCopy(queue, staging, a, sourceOffset: 256, destinationOffset: 512, size: 1024); + Assert.True(queue.Record(commands)); + Assert.Collection( + native.Commands, + command => AssertCopy(command, commands, staging, a, 256, 512, 1024), + command => AssertTrailingConsumerBarrier(command, commands)); + + queue.EnqueueBufferCopy(a, b, sourceOffsetBytes: 640, destinationOffsetBytes: 768, byteCount: 896); + Assert.True(queue.Record(commands)); + Assert.Collection( + native.Commands, + command => AssertCopy(command, commands, staging, a, 256, 512, 1024), + command => AssertTrailingConsumerBarrier(command, commands), + command => AssertMigrationBarrier(command, commands, a, 640, 896), + command => AssertCopy(command, commands, a, b, 640, 768, 896), + command => AssertTrailingConsumerBarrier(command, commands)); + } + + [Fact] + public void UploadQueue_ActualNativeRecordBodyRemainsBoundToRecord() + { + string source = Source("src", "AcDream.App", "Rendering", "Gpu", "Vk", + "VulkanUploadQueue.cs"); + int loopStart = source.IndexOf( + "foreach (BufferCopy2 copy in _bufferCopies)", + StringComparison.Ordinal); + int loopEnd = source.IndexOf( + "foreach (ImageCopy2 copy in _imageCopies)", + loopStart, + StringComparison.Ordinal); + Assert.True(loopStart >= 0 && loopEnd > loopStart); + string loop = source[loopStart..loopEnd]; + + int predicate = loop.IndexOf( + "RequiresDeviceMigrationReadBarrier(copy.Kind)", + StringComparison.Ordinal); + int descriptor = loop.IndexOf( + "CreateDeviceMigrationReadBarrier(", + StringComparison.Ordinal); + int dependency = loop.IndexOf( + "_vk.CmdPipelineBarrier2(commands, &dependency);", + StringComparison.Ordinal); + int copy = loop.IndexOf( + "_vk.CmdCopyBuffer(commands, copy.Source, copy.Destination, 1, ®ion);", + StringComparison.Ordinal); + Assert.True(predicate >= 0 && descriptor > predicate && dependency > descriptor && copy > dependency); + + Assert.Contains( + "BufferCopyKind.HostStaging", + MethodBody(source, "internal void StageBufferWrite(", "internal void StageImageWrite("), + StringComparison.Ordinal); + Assert.Contains( + "BufferCopyKind.DeviceMigration", + MethodBody(source, "internal void EnqueueBufferCopy(", "internal bool Record("), + StringComparison.Ordinal); + + // Independent staging uploads still share the original single + // transfer->draw visibility barrier after all buffer copies. + Assert.Contains( + "SrcAccessMask = AccessFlags2.TransferWriteBit", + source[loopEnd..], + StringComparison.Ordinal); + Assert.Contains( + "DstStageMask = PipelineStageFlags2.VertexInputBit", + source[loopEnd..], + StringComparison.Ordinal); + Assert.Contains( + "DstAccessMask = AccessFlags2.VertexAttributeReadBit", + source[loopEnd..], + StringComparison.Ordinal); + } + + private static VulkanUploadQueue CreateCommandWitnessQueue(RecordingNativeContext native) + { + var queue = (VulkanUploadQueue)RuntimeHelpers.GetUninitializedObject(typeof(VulkanUploadQueue)); + SetField(queue, "_vk", new Silk.NET.Vulkan.Vk(native)); + InitializeCollectionField(queue, "_bufferCopies"); + InitializeCollectionField(queue, "_imageCopies"); + InitializeCollectionField(queue, "_mipBlits"); + InitializeCollectionField(queue, "_imageEntryLayouts"); + return queue; + } + + private static void EnqueueHostStagingCopy( + VulkanUploadQueue queue, + VkBuffer source, + VkBuffer destination, + ulong sourceOffset, + ulong destinationOffset, + ulong size) + { + FieldInfo field = Field("_bufferCopies"); + var copies = Assert.IsAssignableFrom(field.GetValue(queue)); + Type copyType = field.FieldType.GenericTypeArguments.Single(); + object copy = Activator.CreateInstance( + copyType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + source, + destination, + sourceOffset, + destinationOffset, + size, + VulkanUploadQueue.BufferCopyKind.HostStaging, + ], + culture: null) ?? throw new InvalidOperationException("Could not construct pending staging copy."); + copies.Add(copy); + } + + private static void InitializeCollectionField(VulkanUploadQueue queue, string name) + { + FieldInfo field = Field(name); + SetField(queue, name, Activator.CreateInstance(field.FieldType)!); + } + + private static void SetField(VulkanUploadQueue queue, string name, object value) => + Field(name).SetValue(queue, value); + + private static FieldInfo Field(string name) => + typeof(VulkanUploadQueue).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Missing VulkanUploadQueue field {name}."); + + private static void AssertCopy( + RecordedNativeCommand command, + CommandBuffer commands, + VkBuffer source, + VkBuffer destination, + ulong sourceOffset, + ulong destinationOffset, + ulong size) + { + Assert.Equal(RecordedNativeCommandKind.CopyBuffer, command.Kind); + Assert.Equal(commands, command.Commands); + Assert.Equal(source, command.Source); + Assert.Equal(destination, command.Destination); + Assert.Equal(1u, command.RegionCount); + Assert.Equal(sourceOffset, command.Copy.SrcOffset); + Assert.Equal(destinationOffset, command.Copy.DstOffset); + Assert.Equal(size, command.Copy.Size); + } + + private static void AssertMigrationBarrier( + RecordedNativeCommand command, + CommandBuffer commands, + VkBuffer source, + ulong sourceOffset, + ulong size) + { + Assert.Equal(RecordedNativeCommandKind.BufferBarrier, command.Kind); + Assert.Equal(commands, command.Commands); + BufferMemoryBarrier2 barrier = command.BufferBarrier; + Assert.Equal(StructureType.BufferMemoryBarrier2, barrier.SType); + Assert.Equal(PipelineStageFlags2.AllTransferBit, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.TransferWriteBit, barrier.SrcAccessMask); + Assert.Equal(PipelineStageFlags2.AllTransferBit, barrier.DstStageMask); + Assert.Equal(AccessFlags2.TransferReadBit, barrier.DstAccessMask); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.SrcQueueFamilyIndex); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.DstQueueFamilyIndex); + Assert.Equal(source, barrier.Buffer); + Assert.Equal(sourceOffset, barrier.Offset); + Assert.Equal(size, barrier.Size); + } + + private static void AssertTrailingConsumerBarrier( + RecordedNativeCommand command, + CommandBuffer commands) + { + Assert.Equal(RecordedNativeCommandKind.MemoryBarrier, command.Kind); + Assert.Equal(commands, command.Commands); + MemoryBarrier2 barrier = command.MemoryBarrier; + Assert.Equal(StructureType.MemoryBarrier2, barrier.SType); + Assert.Equal(PipelineStageFlags2.AllTransferBit, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.TransferWriteBit, barrier.SrcAccessMask); + Assert.Equal( + PipelineStageFlags2.VertexInputBit | + PipelineStageFlags2.VertexShaderBit | + PipelineStageFlags2.FragmentShaderBit | + PipelineStageFlags2.DrawIndirectBit, + barrier.DstStageMask); + Assert.Equal( + AccessFlags2.VertexAttributeReadBit | + AccessFlags2.IndexReadBit | + AccessFlags2.ShaderReadBit | + AccessFlags2.UniformReadBit | + AccessFlags2.IndirectCommandReadBit, + barrier.DstAccessMask); + } + + private enum RecordedNativeCommandKind + { + BufferBarrier, + CopyBuffer, + MemoryBarrier, + } + + private readonly record struct RecordedNativeCommand( + RecordedNativeCommandKind Kind, + CommandBuffer Commands, + VkBuffer Source, + VkBuffer Destination, + uint RegionCount, + BufferCopy Copy, + BufferMemoryBarrier2 BufferBarrier, + MemoryBarrier2 MemoryBarrier); + + private sealed unsafe class RecordingNativeContext : INativeContext + { + private static RecordingNativeContext? s_active; + + internal RecordingNativeContext() + { + Assert.Null(s_active); + s_active = this; + } + + internal List Commands { get; } = []; + + public nint GetProcAddress(string proc, int? slot = null) => proc switch + { + "vkCmdCopyBuffer" => + (nint)(delegate* unmanaged) + &CaptureCopyBuffer, + "vkCmdPipelineBarrier2" => + (nint)(delegate* unmanaged) + &CapturePipelineBarrier, + _ => (nint)(delegate* unmanaged)&NoOp, + }; + + public bool TryGetProcAddress(string proc, out nint addr, int? slot = null) + { + addr = GetProcAddress(proc, slot); + return true; + } + + public void Dispose() + { + if (ReferenceEquals(s_active, this)) + s_active = null; + } + + [UnmanagedCallersOnly] + private static void CaptureCopyBuffer( + CommandBuffer commands, + VkBuffer source, + VkBuffer destination, + uint regionCount, + BufferCopy* regions) + { + RecordingNativeContext active = s_active!; + active.Commands.Add(new RecordedNativeCommand( + RecordedNativeCommandKind.CopyBuffer, + commands, + source, + destination, + regionCount, + regionCount == 0 ? default : regions[0], + default, + default)); + } + + [UnmanagedCallersOnly] + private static void CapturePipelineBarrier(CommandBuffer commands, DependencyInfo* dependency) + { + RecordingNativeContext active = s_active!; + if (dependency->BufferMemoryBarrierCount == 1) + { + active.Commands.Add(new RecordedNativeCommand( + RecordedNativeCommandKind.BufferBarrier, + commands, + default, + default, + 0, + default, + dependency->PBufferMemoryBarriers[0], + default)); + return; + } + + active.Commands.Add(new RecordedNativeCommand( + RecordedNativeCommandKind.MemoryBarrier, + commands, + default, + default, + 0, + default, + default, + dependency->PMemoryBarriers[0])); + } + + [UnmanagedCallersOnly] + private static void NoOp() + { + } + } + + private static void AssertDepthEntry( + ImageMemoryBarrier2 barrier, + VkImage image, + PipelineStageFlags2 destinationStage, + AccessFlags2 destinationAccess) + { + Assert.Equal(StructureType.ImageMemoryBarrier2, barrier.SType); + Assert.Equal(PipelineStageFlags2.AllCommandsBit, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.None, barrier.SrcAccessMask); + Assert.Equal(destinationStage, barrier.DstStageMask); + Assert.Equal(destinationAccess, barrier.DstAccessMask); + Assert.Equal(ImageLayout.Undefined, barrier.OldLayout); + Assert.Equal(ImageLayout.DepthStencilAttachmentOptimal, barrier.NewLayout); + Assert.Equal(image, barrier.Image); + AssertDepthStencilImage(barrier.SubresourceRange); + } + + private static void AssertDepthExit( + ImageMemoryBarrier2 barrier, + VkImage image, + PipelineStageFlags2 sourceStage, + AccessFlags2 sourceAccess) + { + Assert.Equal(StructureType.ImageMemoryBarrier2, barrier.SType); + Assert.Equal(sourceStage, barrier.SrcStageMask); + Assert.Equal(sourceAccess, barrier.SrcAccessMask); + Assert.Equal(PipelineStageFlags2.FragmentShaderBit, barrier.DstStageMask); + Assert.Equal(AccessFlags2.ShaderReadBit, barrier.DstAccessMask); + Assert.Equal(ImageLayout.DepthStencilAttachmentOptimal, barrier.OldLayout); + Assert.Equal(ImageLayout.DepthStencilReadOnlyOptimal, barrier.NewLayout); + Assert.Equal(image, barrier.Image); + AssertDepthStencilImage(barrier.SubresourceRange); + } + + private static void AssertColorImage(ImageSubresourceRange range) + { + Assert.Equal(ImageAspectFlags.ColorBit, range.AspectMask); + Assert.Equal(0u, range.BaseMipLevel); + Assert.Equal(1u, range.LevelCount); + Assert.Equal(0u, range.BaseArrayLayer); + Assert.Equal(1u, range.LayerCount); + } + + private static void AssertDepthStencilImage(ImageSubresourceRange range) + { + Assert.Equal(ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, range.AspectMask); + Assert.Equal(0u, range.BaseMipLevel); + Assert.Equal(Silk.NET.Vulkan.Vk.RemainingMipLevels, range.LevelCount); + Assert.Equal(0u, range.BaseArrayLayer); + Assert.Equal(Silk.NET.Vulkan.Vk.RemainingArrayLayers, range.LayerCount); + } + + private static string MethodBody(string source, string startToken, string endToken) + { + int start = source.IndexOf(startToken, StringComparison.Ordinal); + int end = source.IndexOf(endToken, start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + return source[start..end]; + } + + private static string Normalize(string value) => value.Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string Source(params string[] path) => + File.ReadAllText(Path.Combine([RepositoryRoot(), .. path])); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate the repository root."); + } +}