Crafter.Graphics/tests/UIFusedShader/main.cpp
Jorijn van der Graaf 2b6f90b12c test(ui): the descriptor-set fallback draws the same frame as the heap
UIFallbackParity renders one scene through both binding models and
compares the frames. It starts itself twice, once as is and once with
CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP, so the fallback cannot drift from
the heap path on a machine that never takes it. The scene reaches what
HelloUI does not: the four standalone kernels next to the fused one, a
sampled image with two samplers indexed per item, an image slot and a
sampler slot that are freed and registered again, a resize, an image
created with ImageVulkan::Create's default consumer stage, and a
generic Dispatch whose push payload is not a whole number of words.

On a driver without the heap both runs take the fallback, and the test
then only checks that the scene renders. Validation errors raised
while rendering fail a run, which only bites when
CRAFTER_GRAPHICS_VALIDATION is set. They are counted before the frame
capture because Window::SaveFrame raises one itself (#157).

UIFusedShader now also compiles and validates the descriptor-set build
of the fused kernel and pins its push-constant layout: both builds are
fed the same UIFusedHeader bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00

200 lines
8.3 KiB
C++

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Regression test for issue #47: the fused UI uber-kernel
// (shaders/ui-fused.comp.glsl) and its C++ push-constant mirror
// (Crafter::UIFusedHeader). The fused kernel is large and hand-written, and
// its push-constant block must stay byte-for-byte identical to UIFusedHeader
// or DispatchFused silently corrupts every fused frame. This test:
//
// 1. compiles the real ui-fused.comp.glsl with glslang (same target-env the
// engine builds it with), proving the uber-kernel still compiles and its
// GL_GOOGLE_include of ui-shared.glsl resolves;
// 2. runs spirv-val over the result under the engine's block-layout flags;
// 3. asserts exactly one push-constant variable survives (a well-formed
// single block, like every other UI shader);
// 4. parses the push-constant struct's member Offset decorations and asserts
// they are exactly {0,16,32,48,64,80,96,112} — the layout of
// UIFusedHeader (four uvec4 headers at 0/16/32/48 then four vec4 clip
// rects at 64/80/96/112, 128 bytes total). Any add/remove/reorder of a
// member shifts these offsets and fails here, catching a GLSL/C++ drift
// that the C++-side static_assert(sizeof==128) alone cannot;
// 5. repeats steps 1-4 for the descriptor-set build of the same source
// (-DCRAFTER_GRAPHICS_DESCRIPTOR_SETS, shipped as ui-fused.comp.sets.spv
// for drivers without VK_EXT_descriptor_heap). Both builds are fed the
// same UIFusedHeader bytes, so both must keep the same block layout.
//
// glslang and spirv-val are invoked at runtime, so the test declares them as
// required tools (see project.cpp). It needs no GPU device.
#include <cstdlib>
import Crafter.Graphics; // for the static_assert(sizeof(UIFusedHeader)==128)
import std;
using namespace Crafter;
namespace {
namespace fs = std::filesystem;
int RunCommand(const std::string& cmd) {
int status = std::system(cmd.c_str());
if (status == -1) return -1;
return (status & 0x7f) == 0 ? ((status >> 8) & 0xff) : 128 + (status & 0x7f);
}
std::vector<std::uint32_t> ReadSpirv(const fs::path& p) {
std::ifstream f(p, std::ios::binary | std::ios::ate);
if (!f) return {};
std::streamsize size = f.tellg();
f.seekg(0);
std::vector<std::uint32_t> words(static_cast<std::size_t>(size) / sizeof(std::uint32_t));
f.read(reinterpret_cast<char*>(words.data()), size);
return words;
}
// Count OpVariable instructions in the PushConstant storage class (SC == 9).
int CountPushConstantVariables(const std::vector<std::uint32_t>& words) {
constexpr std::uint32_t OpVariable = 59;
constexpr std::uint32_t StorageClassPushConstant = 9;
int count = 0;
for (std::size_t i = 5; i < words.size();) {
std::uint32_t len = words[i] >> 16;
if (len == 0 || i + len > words.size()) break;
if ((words[i] & 0xFFFFu) == OpVariable && len >= 4 && words[i + 3] == StorageClassPushConstant)
++count;
i += len;
}
return count;
}
// The single PushConstant struct's member byte offsets, sorted ascending.
// Finds the OpTypePointer in the PushConstant storage class (SC == 9), takes
// the struct type it points at, and collects every Offset (decoration 35)
// declared on that struct via OpMemberDecorate.
std::vector<std::uint32_t> PushConstantMemberOffsets(const std::vector<std::uint32_t>& words) {
constexpr std::uint32_t OpTypePointer = 32;
constexpr std::uint32_t OpMemberDecorate = 72;
constexpr std::uint32_t StorageClassPushConstant = 9;
constexpr std::uint32_t DecorationOffset = 35;
std::uint32_t structId = 0;
for (std::size_t i = 5; i < words.size();) {
std::uint32_t len = words[i] >> 16;
if (len == 0 || i + len > words.size()) break;
// OpTypePointer: [op][result id][storage class][pointee type id]
if ((words[i] & 0xFFFFu) == OpTypePointer && len >= 4 &&
words[i + 2] == StorageClassPushConstant) {
structId = words[i + 3];
break;
}
i += len;
}
if (structId == 0) return {};
std::vector<std::uint32_t> offsets;
for (std::size_t i = 5; i < words.size();) {
std::uint32_t len = words[i] >> 16;
if (len == 0 || i + len > words.size()) break;
// OpMemberDecorate: [op][struct id][member][decoration][operands...]
if ((words[i] & 0xFFFFu) == OpMemberDecorate && len >= 5 &&
words[i + 1] == structId && words[i + 3] == DecorationOffset) {
offsets.push_back(words[i + 4]);
}
i += len;
}
std::ranges::sort(offsets);
offsets.erase(std::ranges::unique(offsets).begin(), offsets.end());
return offsets;
}
// Locate shaders/ui-fused.comp.glsl whether the test runs from the build
// output dir or the project root (mirrors ShapeTextCache's font probing).
fs::path LocateShader() {
const std::array<fs::path, 4> candidates = {
"shaders/ui-fused.comp.glsl",
"../shaders/ui-fused.comp.glsl",
"../../shaders/ui-fused.comp.glsl",
"../../../shaders/ui-fused.comp.glsl",
};
for (const auto& c : candidates) {
std::error_code ec;
if (fs::exists(c, ec)) return c;
}
return {};
}
} // namespace
int main() {
// Sanity: the C++ mirror is the size the shader layout assumes.
static_assert(sizeof(UIFusedHeader) == 128);
const fs::path src = LocateShader();
if (src.empty()) {
std::println(std::cerr, "could not locate shaders/ui-fused.comp.glsl from {}",
fs::current_path().string());
return 1;
}
const fs::path dir = fs::temp_directory_path() / "crafter-uifused-test";
std::error_code ec;
fs::create_directories(dir, ec);
// The heap build first, then the descriptor-set build of the same source.
struct Variant { std::string_view label; std::string_view define; std::string_view file; };
constexpr Variant variants[] = {
{ "descriptor-heap", "", "ui-fused.comp.spv" },
{ "descriptor-set", "-DCRAFTER_GRAPHICS_DESCRIPTOR_SETS ", "ui-fused.comp.sets.spv" },
};
for (const Variant& variant : variants) {
const fs::path spv = dir / variant.file;
// 1. Compile the real uber-kernel. GL_GOOGLE_include resolves
// ui-shared.glsl relative to the source directory.
std::string compile = "glslang --target-env vulkan1.4 " + std::string(variant.define) + "-V -S comp \""
+ src.string() + "\" -o \"" + spv.string() + "\" > /dev/null";
if (RunCommand(compile) != 0) {
std::println(std::cerr, "glslang failed to compile ui-fused.comp.glsl ({} build)", variant.label);
return 1;
}
std::vector<std::uint32_t> words = ReadSpirv(spv);
if (words.size() < 5) {
std::println(std::cerr, "could not read compiled SPIR-V");
return 1;
}
// 2. spirv-val under the engine's relaxed block-layout flags.
std::string validate = "spirv-val \"" + spv.string()
+ "\" --relax-block-layout --scalar-block-layout --target-env vulkan1.4";
if (RunCommand(validate) != 0) {
std::println(std::cerr, "spirv-val rejected {}", variant.file);
return 1;
}
// 3. Exactly one push-constant block.
int pcVars = CountPushConstantVariables(words);
if (pcVars != 1) {
std::println(std::cerr, "expected exactly 1 push-constant variable, found {}", pcVars);
return 1;
}
// 4. Member offsets must match UIFusedHeader's layout exactly.
const std::vector<std::uint32_t> expected = {0, 16, 32, 48, 64, 80, 96, 112};
std::vector<std::uint32_t> offsets = PushConstantMemberOffsets(words);
if (offsets != expected) {
std::print(std::cerr, "push-constant member offsets mismatch — expected {{");
for (auto o : expected) std::print(std::cerr, "{} ", o);
std::print(std::cerr, "}}, got {{");
for (auto o : offsets) std::print(std::cerr, "{} ", o);
std::println(std::cerr, "}}");
return 1;
}
std::println(std::cout,
"ui-fused shader ok, {} build (push-constant vars: {}, member offsets pinned to UIFusedHeader, 128 bytes)",
variant.label, pcVars);
}
return 0;
}