/* Crafter®.Graphics Copyright (C) 2026 Catcrafts® catcrafts.net This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3.0 as published by the Free Software Foundation; This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ // Issue #67: the compressed Mesh::Build path used to keep `compressedStaging` // — a per-mesh host-visible staging buffer holding the GDeflate streams — // alive for the whole life of the mesh, pinning host-visible memory long after // the one decompress that reads it has retired. Build now releases it via // VulkanBuffer::DeferredClear() right after recording the decompress, so the // fence-keyed deletion queue (#101/#102) frees it once that submit's frame has // cleared instead of pinning it forever. // // This drives the real hardware GPU-decompress path (VK_EXT_memory_decompression, // GDeflate 1.0) on a headless device — no swapchain/window needed, a decompress + // BLAS build only touches the queue + command pool — and asserts: // - After Build, mesh.compressedStaging owns no handle (released). // - Exactly that one allocation was handed to Device's deletion queue, tagged // with the current frameCounter (so it retires on a later frame, not now). // - The decompress + BLAS build still complete with ZERO validation errors and // a non-zero BLAS address — proof the staging genuinely outlived the submit // (the queue had not yet retired it), so this is no use-after-free. // - The GPU-decompressed vertex/index data read back byte-equal to the source, // so releasing the staging didn't corrupt the result. // - The deferred entry actually retires (and frees) once framesInFlight frames // elapse, and not before — the whole point of routing through the queue. // // On hardware without VK_EXT_memory_decompression the compressed Build takes the // CPU-decode fallback, which never allocates compressedStaging, so the new // behavior is moot — the test reports that and skips the GPU-path assertions. #include "vulkan/vulkan.h" #include import Crafter.Graphics; import Crafter.Asset; import Crafter.Math; import std; using namespace Crafter; namespace fs = std::filesystem; namespace { int failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); if (!ok) ++failures; } // One-time command buffer helpers — record, submit, block. Mirrors the other // headless RT tests (BLASBuildOptions). VkCommandBuffer BeginCmd() { VkCommandBufferAllocateInfo allocInfo { .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = Device::commandPool, .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 1, }; VkCommandBuffer cmd = VK_NULL_HANDLE; Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd)); VkCommandBufferBeginInfo beginInfo { .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, }; Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo)); return cmd; } void SubmitWait(VkCommandBuffer cmd) { Device::CheckVkResult(vkEndCommandBuffer(cmd)); VkSubmitInfo submitInfo { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &cmd, }; Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE)); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); } // A unit cube (8 verts, 36 indices) — small enough to fit one GDeflate tile, // enough topology for a real BLAS. MeshAsset MakeCubeMesh() { MeshAsset mesh; mesh.vertexes = { {-1.f, -1.f, -1.f}, { 1.f, -1.f, -1.f}, { 1.f, 1.f, -1.f}, {-1.f, 1.f, -1.f}, {-1.f, -1.f, 1.f}, { 1.f, -1.f, 1.f}, { 1.f, 1.f, 1.f}, {-1.f, 1.f, 1.f}, }; mesh.indexes = { 0,1,2, 0,2,3, 4,6,5, 4,7,6, 0,4,5, 0,5,1, 3,2,6, 3,6,7, 1,5,6, 1,6,2, 0,3,7, 0,7,4, }; return mesh; } } // namespace int main() { Device::Initialize(); Device::validationErrorCount = 0; // Build → compress → load a cube via the real asset pipeline. MeshAsset srcMesh = MakeCubeMesh(); const fs::path meshPath = fs::temp_directory_path() / "crafter_meshdecompress_staging_release.cmesh"; srcMesh.SaveCompressed(meshPath); CompressedMeshAsset asset = LoadCompressedMesh(meshPath); fs::remove(meshPath); Check(asset.vertexCount == srcMesh.vertexes.size() && asset.indexCount == srcMesh.indexes.size(), "LoadCompressedMesh round-trips the cube header"); if (!Device::memoryDecompressionSupported) { // No GPU codec → compressed Build takes the CPU-decode fallback, which // never allocates compressedStaging. The release behavior under test // only exists on the GPU path, so there is nothing to assert here. std::println("VK_EXT_memory_decompression absent — GPU decompress path " "not exercised; skipping staging-release assertions."); return failures == 0 ? 0 : 1; } // Control the deferred-deletion clock: enqueue at frame 0, retire after 2. Device::framesInFlight = 2; Device::frameCounter = 0; Device::deletionQueue.clear(); Mesh mesh; VkCommandBuffer cmd = BeginCmd(); // allowUpdate=true so the per-mesh BLAS scratch is RETAINED for refit // (issue #66) rather than deferred-cleared after the build. A static // (allowUpdate=false) build would also hand its scratch to the deletion // queue, making the count below 2; keeping it isolates this test to the // one allocation it cares about — the released compressed staging (#67). // The staging-release behavior under test is independent of allowUpdate. mesh.Build(asset, cmd, RTBuildOptions{ .allowUpdate = true }); // ── The fix: staging is released to the queue at record time, not pinned. ── Check(mesh.compressedStaging.buffer == VK_NULL_HANDLE, "Build releases compressedStaging (no handle pinned to the mesh)"); Check(Device::deletionQueue.size() == 1, "exactly one allocation (the staging) handed to the deletion queue"); const bool taggedNow = !Device::deletionQueue.empty() && Device::deletionQueue.front().retireAfter == 0 && Device::deletionQueue.front().buffer != VK_NULL_HANDLE; Check(taggedNow, "deferred staging is tagged with the enqueue frame and still a live handle"); // The decompress recorded above still references the staging's address; it // must outlive THIS submit. The queue hasn't retired it (frame 0, retires at // 2), so the GPU reads valid memory. Zero validation errors + a real BLAS // address prove there's no use-after-free. SubmitWait(cmd); Check(mesh.blasAddr != 0, "compressed Build produced a non-zero BLAS address"); Check(Device::validationErrorCount == 0, "decompress + BLAS build raised no validation errors"); // The GPU decompress wrote into the device-local vertex/index buffers // (issue #73 placed RT geometry in VRAM, so they are no longer host-mapped). // Copy them back to host-visible staging — the proper way to inspect // device-local memory — and compare: releasing the compressed staging must // not have corrupted the decompressed result. The geometry buffers carry // TRANSFER_SRC for exactly this (#73). VulkanBuffer, true> vertReadback; VulkanBuffer idxReadback; vertReadback.Resize(VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, static_cast(srcMesh.vertexes.size())); idxReadback.Resize(VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, static_cast(srcMesh.indexes.size())); { // Safe to copy without a barrier: SubmitWait above wait-idled the queue, // so the decompress + BLAS reads of these buffers have fully retired. VkCommandBuffer rcmd = BeginCmd(); VkBufferCopy vRegion { .srcOffset = 0, .dstOffset = 0, .size = vertReadback.size }; vkCmdCopyBuffer(rcmd, mesh.vertexBuffer.buffer, vertReadback.buffer, 1, &vRegion); VkBufferCopy iRegion { .srcOffset = 0, .dstOffset = 0, .size = idxReadback.size }; vkCmdCopyBuffer(rcmd, mesh.indexBuffer.buffer, idxReadback.buffer, 1, &iRegion); SubmitWait(rcmd); } vertReadback.FlushHost(); idxReadback.FlushHost(); const bool vertsMatch = std::memcmp( vertReadback.value, srcMesh.vertexes.data(), srcMesh.vertexes.size() * sizeof(srcMesh.vertexes[0])) == 0; const bool idxMatch = std::memcmp( idxReadback.value, srcMesh.indexes.data(), srcMesh.indexes.size() * sizeof(srcMesh.indexes[0])) == 0; Check(vertsMatch, "GPU-decompressed vertices are byte-equal to the source"); Check(idxMatch, "GPU-decompressed indices are byte-equal to the source"); // ── Retire timing: the staging frees once framesInFlight frames elapse, // and not a frame before. We've already wait-idled, so freeing is safe. ── Device::ReclaimDeletions(); Check(Device::deletionQueue.size() == 1, "staging is NOT freed before framesInFlight frames have elapsed"); Device::frameCounter = 2; // retireAfter(0) + framesInFlight(2) <= 2 Device::ReclaimDeletions(); Check(Device::deletionQueue.empty(), "staging is freed once its retire frame is reached"); std::println("{}", failures == 0 ? "ALL PASS" : "FAILURES PRESENT"); return failures == 0 ? 0 : 1; }