// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; namespace fs = std::filesystem; using namespace Crafter; // One shader source can be registered more than once, each entry with its own // defines and a variant that names its output ("tint.spv", "tint.red.spv"). // Build() compiles the entries in parallel into one directory, which is where // the feature can go wrong in ways the Shader API alone does not show: // // - both entries must land in the bin dir, with different contents; // - an entry added later must still be compiled when the other one's output // is already there and up to date; // - two entries that would write the same file (defines set, variant // forgotten) used to race silently: the file held whichever compile // finished last, the build reported success, and Check() called both up // to date from then on. That has to be a build error naming the cause. // // The fixture's `#if TINT_RED` also pins that a bare define is defined to 1, // like the compiler's -D: an empty definition would not compile. namespace { std::int32_t Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++Failures; } } // Shader outputs are compared by mtime against the source, and the test // adds an entry between passes, so work on a fresh copy outside the repo. fs::path StageFixture() { fs::path source = fs::current_path() / "tests" / "ShaderVariantBuild" / "fixture"; fs::path staged = fs::temp_directory_path() / "crafter-build-shader-variant-build"; fs::remove_all(staged); fs::copy(source, staged, fs::copy_options::recursive); return staged; } Configuration MakeApp(const fs::path& staged) { Configuration app; app.path = staged; app.name = "tinted"; app.outputName = "tinted"; app.target = HostTarget(); app.type = ConfigurationType::Executable; std::array ifaces = {}; std::array impls = { "main" }; app.GetInterfacesAndImplementations(ifaces, impls); return app; } Shader& AddTint(Configuration& app, const fs::path& staged) { return app.shaders.emplace_back(staged / "tint.frag", std::string("main"), ShaderType::Fragment); } BuildResult BuildOnce(Configuration& app) { // A fresh depResults per pass: the map memoizes a Configuration's // build for the duration of one pass. std::unordered_map> depResults; std::mutex depMutex; return Build(app, depResults, depMutex); } std::string ReadAll(const fs::path& file) { std::ifstream stream(file, std::ios::binary); return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); } } int main() { const fs::path staged = StageFixture(); // Pass 1: the plain entry alone. Configuration app = MakeApp(staged); AddTint(app, staged); BuildResult first = BuildOnce(app); Check(first.result.empty(), std::format("the plain entry builds: {}", first.result)); const fs::path plainSpv = app.BinDir() / "tint.spv"; const fs::path redSpv = app.BinDir() / "tint.red.spv"; Check(fs::exists(plainSpv), "tint.spv is in the bin dir"); Check(!fs::exists(redSpv), "no variant output before the variant is registered"); // Pass 2: the variant is added while tint.spv is already up to date. It // has its own staleness check and must be compiled, into its own file. Shader& red = AddTint(app, staged); red.defines = {"TINT_RED"}; red.variant = "red"; BuildResult second = BuildOnce(app); Check(second.result.empty(), std::format("plain + variant builds: {}", second.result)); Check(fs::exists(redSpv), "tint.red.spv is in the bin dir"); const std::string plainBytes = ReadAll(plainSpv); const std::string redBytes = ReadAll(redSpv); Check(!plainBytes.empty() && !redBytes.empty(), "both outputs have content"); Check(plainBytes != redBytes, "the variant took the other #if branch"); // Pass 3: the same define without a variant. Both entries would write // tint.spv, so the build has to stop and say so, and must leave the file // from pass 2 alone. Configuration clash = MakeApp(staged); AddTint(clash, staged); Shader& unnamed = AddTint(clash, staged); unnamed.defines = {"TINT_RED"}; BuildResult third = BuildOnce(clash); Check(!third.result.empty(), "two entries with one output name fail the build"); Check(third.result.contains("tint.spv") && third.result.contains("variant"), std::format("the error names the file and the fix: {}", third.result)); Check(ReadAll(plainSpv) == plainBytes, "the existing output is not overwritten by the failed build"); std::error_code ec; fs::remove_all(staged, ec); if (Failures > 0) { std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; }