From deb37c308bd7fe286c863aafb22340467724716b Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:35:02 +0300 Subject: [PATCH] Capture complete gameplay and toolchain build inputs --- cmake/BuildService.cmake | 8 +- include/faset/editor/build_cache.hpp | 28 +++++ src/editor/build_cache.cpp | 154 +++++++++++++++++++++++++++ src/editor/build_service.cpp | 10 +- src/editor/session.cpp | 16 +-- tests/build_cache_tests.cpp | 102 ++++++++++++++++++ tests/build_schema_tests.cpp | 9 ++ tests/build_schema_tool.cpp | 2 + 8 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 include/faset/editor/build_cache.hpp create mode 100644 src/editor/build_cache.cpp create mode 100644 tests/build_cache_tests.cpp diff --git a/cmake/BuildService.cmake b/cmake/BuildService.cmake index 7d559d3..9f8f889 100644 --- a/cmake/BuildService.cmake +++ b/cmake/BuildService.cmake @@ -1,8 +1,14 @@ -add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp) +add_library(faset_build_service STATIC + ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp + ${PROJECT_SOURCE_DIR}/src/editor/build_cache.cpp) target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include) target_compile_features(faset_build_service PUBLIC cxx_std_20) target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads) if(BUILD_TESTING) + add_executable(faset_build_cache_tests ${PROJECT_SOURCE_DIR}/tests/build_cache_tests.cpp) + target_link_libraries(faset_build_cache_tests PRIVATE faset_build_service) + target_compile_definitions(faset_build_cache_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") + add_test(NAME build_cache COMMAND faset_build_cache_tests) add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp) target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets) target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") diff --git a/include/faset/editor/build_cache.hpp b/include/faset/editor/build_cache.hpp new file mode 100644 index 0000000..7f4916f --- /dev/null +++ b/include/faset/editor/build_cache.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +namespace faset::editor { + +struct BuildInputs { + std::string source_hash; + std::string recipe_hash; + std::string toolchain_hash; + std::string fingerprint() const; +}; + +// Hash all files in Scripts and the active Lua declaration, not only the two +// conventional gameplay files. Both Editor stale-state and builds use this key. +std::string gameplay_source_hash(const std::filesystem::path& project_root, + const scripting::LuaProject& lua); +BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua); + +// A compiler replaced at the same path may be invisible to Ninja. Invalidate +// only this generated native tree; published build generations stay intact. +void ensure_native_toolchain_stamp(const std::filesystem::path& native_directory, + const BuildInputs& inputs); + +} // namespace faset::editor diff --git a/src/editor/build_cache.cpp b/src/editor/build_cache.cpp new file mode 100644 index 0000000..1c91495 --- /dev/null +++ b/src/editor/build_cache.cpp @@ -0,0 +1,154 @@ +#include + +#include +#include +#include +#include +#include + +namespace faset::editor { +namespace fs = std::filesystem; +namespace { +std::string normalized_hash(const Json& value) { + return sha256(value.dump()); +} +std::string configured_tool(const BuildConfig& config, std::string_view key, + std::string fallback) { + const auto prefix = "-D" + std::string(key) + "="; + for (const auto& argument : config.configure_arguments) + if (argument.starts_with(prefix)) + fallback = argument.substr(prefix.size()); + return fallback; +} +fs::path resolve_tool(const std::string& name) { + const auto supplied = path_from_utf8(name); + if (supplied.has_parent_path()) + return fs::absolute(supplied).lexically_normal(); + try { + return find_executable(name); + } catch (const std::exception&) { + // CMake reports a missing compiler. Keep the identity deterministic so + // fixture build tools can exercise publication without native compiling. + return supplied; + } +} +Json tool_identity(const std::string& name) { + const auto resolved = resolve_tool(name); + Json result = {{"path", path_to_utf8(resolved)}}; + if (fs::is_regular_file(resolved)) + result["sha256"] = sha256_file(resolved); + else + result["missing"] = true; + return result; +} +std::string slang_name(const BuildConfig& config) { + auto configured = configured_tool(config, "SLANGC_EXECUTABLE", ""); + if (!configured.empty()) + return configured; +#ifdef _WIN32 + const auto local = config.engine_root / ".cache/slang/bin/slangc.exe"; +#else + const auto local = config.engine_root / ".cache/slang/bin/slangc"; +#endif + if (fs::is_regular_file(local)) + return path_to_utf8(local); + if (const char* sdk = std::getenv("VULKAN_SDK")) { +#ifdef _WIN32 + const auto sdk_tool = path_from_utf8(sdk) / "Bin/slangc.exe"; +#else + const auto sdk_tool = path_from_utf8(sdk) / "bin/slangc"; +#endif + if (fs::is_regular_file(sdk_tool)) + return path_to_utf8(sdk_tool); + } + return "slangc"; +} +Json recipe_files(const fs::path& engine_root) { + Json files = Json::object(); + for (const auto& relative : {"CMakeLists.txt", "dependencies.lock.json", + "tools/compile_shader.py"}) { + const auto path = engine_root / relative; + files[relative] = fs::is_regular_file(path) ? Json(sha256_file(path)) : Json(nullptr); + } + const auto cmake = engine_root / "cmake"; + if (fs::is_directory(cmake)) + for (const auto& entry : fs::directory_iterator(cmake)) + if (entry.is_regular_file() && entry.path().extension() == ".cmake") + files[generic_path_to_utf8(entry.path().lexically_relative(engine_root))] = + sha256_file(entry.path()); + return files; +} +} // namespace + +std::string BuildInputs::fingerprint() const { + return normalized_hash({{"format", "faset.build-inputs.v1"}, + {"source", source_hash}, + {"recipe", recipe_hash}, + {"toolchain", toolchain_hash}}); +} + +std::string gameplay_source_hash(const fs::path& project_root, const scripting::LuaProject& lua) { + Json files = Json::object(); + const auto scripts = project_root / "Scripts"; + if (fs::is_symlink(fs::symlink_status(scripts))) + throw std::runtime_error("Scripts directory must not be a symlink"); + if (fs::is_directory(scripts)) + for (const auto& entry : fs::recursive_directory_iterator(scripts)) { + if (entry.is_symlink()) + throw std::runtime_error("Scripts source cannot be a symlink: " + + path_to_utf8(entry.path())); + if (entry.is_regular_file()) + files[generic_path_to_utf8(entry.path().lexically_relative(project_root))] = + sha256_file(entry.path()); + } + return normalized_hash({{"format", "faset.gameplay-sources.v1"}, + {"files", files}, + {"lua", lua.fingerprint}}); +} + +BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua) { + BuildInputs result; + result.source_hash = gameplay_source_hash(config.project_root, lua); + result.recipe_hash = normalized_hash({{"format", "faset.build-recipe.v1"}, + {"generator", config.generator}, + {"configuration", config.configuration}, + {"export_configuration", config.export_configuration}, + {"configure_arguments", config.configure_arguments}, + {"engine_files", recipe_files(config.engine_root)}}); +#ifdef _WIN32 + constexpr auto default_c = "clang-cl", default_cxx = "clang-cl"; +#else + constexpr auto default_c = "clang", default_cxx = "clang++"; +#endif + result.toolchain_hash = normalized_hash( + {{"format", "faset.native-toolchain.v1"}, + {"cmake", tool_identity(config.cmake)}, + {"c", tool_identity(configured_tool(config, "CMAKE_C_COMPILER", default_c))}, + {"cxx", tool_identity(configured_tool(config, "CMAKE_CXX_COMPILER", default_cxx))}, + {"slang", tool_identity(slang_name(config))}}); + return result; +} + +void ensure_native_toolchain_stamp(const fs::path& native_directory, + const BuildInputs& inputs) { + const auto stamp = native_directory / ".faset-toolchain.json"; + bool matching = false; + if (fs::is_regular_file(stamp)) + try { + const auto existing = read_json(stamp); + matching = existing.value("format", std::string()) == "faset.toolchain-stamp" && + existing.value("version", 0) == 1 && + existing.value("toolchain_hash", std::string()) == inputs.toolchain_hash; + } catch (const std::exception&) { + matching = false; + } + if (!matching && fs::exists(native_directory)) + fs::remove_all(native_directory); + fs::create_directories(native_directory); + if (!matching) + atomic_write_json(stamp, {{"format", "faset.toolchain-stamp"}, + {"version", 1}, + {"toolchain_hash", inputs.toolchain_hash}}); +} + +} // namespace faset::editor diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 5ad1c1c..1afb341 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -244,6 +245,7 @@ struct BuildService::Impl { const auto native_directory = config.build_directory / configuration; checkpoint(job, "Configuring gameplay", .05); job.lua = scripting::loadLuaProject(config.project_root); + const auto inputs = capture_build_inputs(config, job.lua); const auto cpp = config.project_root / "Scripts" / "Gameplay.cpp"; const auto hpp = config.project_root / "Scripts" / "Gameplay.hpp"; const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp); @@ -252,7 +254,7 @@ struct BuildService::Impl { "or Lua entry scripts declared in project.faset.json"); const auto cpp_source = has_cpp ? read_text(cpp) : std::string{}; const auto hpp_source = has_hpp ? read_text(hpp) : std::string{}; - fs::create_directories(native_directory); + ensure_native_toolchain_stamp(native_directory, inputs); std::vector arguments = {config.cmake, "-S", path_to_utf8(config.engine_root), @@ -355,9 +357,9 @@ struct BuildService::Impl { if (job.lua.enabled() && scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint) throw std::runtime_error("Lua build snapshot changed during schema export"); - if (scripting::loadLuaProject(config.project_root).fingerprint != job.lua.fingerprint || - has_cpp != fs::is_regular_file(cpp) || has_hpp != fs::is_regular_file(hpp) || - (has_cpp && (read_text(cpp) != cpp_source || read_text(hpp) != hpp_source))) + if (gameplay_source_hash(config.project_root, + scripting::loadLuaProject(config.project_root)) != + inputs.source_hash) throw std::runtime_error("Gameplay sources changed during the build; build again"); fs::rename(staging, generation); atomic_write_json(config.cache_root / "last_build.json", diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 7884b49..b246b24 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -132,20 +133,7 @@ Json Session::assets_list() const { } std::string Session::source_signature() const { const auto lua = scripting::loadLuaProject(config_.project_root); - const auto directory = config_.project_root / "Scripts"; - std::vector files; - if (std::filesystem::exists(directory)) - for (const auto& file : std::filesystem::recursive_directory_iterator(directory)) - if (file.is_regular_file() && (!lua.enabled() || file.path().extension() != ".lua")) - files.push_back(file.path()); - std::sort(files.begin(), files.end()); - std::string contents; - for (const auto& file : files) - contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" + - sha256_file(file) + "\n"; - if (lua.enabled()) - contents += "lua:" + lua.fingerprint + "\n"; - return sha256(contents); + return gameplay_source_hash(config_.project_root, lua); } Json Session::schema_status() const { try { diff --git a/tests/build_cache_tests.cpp b/tests/build_cache_tests.cpp new file mode 100644 index 0000000..8307888 --- /dev/null +++ b/tests/build_cache_tests.cpp @@ -0,0 +1,102 @@ +#include +#include +#include +#include +#include + +using namespace faset; +namespace fs = std::filesystem; + +namespace { +void check(bool condition, std::string_view message) { + if (!condition) + throw std::runtime_error(std::string(message)); +} +void run(const fs::path& root) { + const auto project = root / "project"; + const auto scripts = project / "Scripts"; + atomic_write(scripts / "Gameplay.cpp", "#include \"Gameplay.hpp\"\n"); + atomic_write(scripts / "Gameplay.hpp", "#pragma once\n"); + atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 1\n"); + const auto compiler = root / "fake-compiler"; + const auto cmake = root / "fake-cmake"; + const auto slang = root / "fake-slangc"; + atomic_write(compiler, "compiler-v1\n"); + atomic_write(cmake, "cmake-v1\n"); + atomic_write(slang, "slang-v1\n"); + editor::BuildConfig config; + config.project_root = project; + config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE); + config.build_directory = project / ".faset/build"; + config.cmake = path_to_utf8(cmake); + config.configure_arguments = { + "-DCMAKE_C_COMPILER=" + path_to_utf8(compiler), + "-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler), + "-DSLANGC_EXECUTABLE=" + path_to_utf8(slang)}; + + const scripting::LuaProject none; + const auto initial = editor::capture_build_inputs(config, none); + atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n"); + const auto changed_header = editor::capture_build_inputs(config, none); + check(initial.source_hash != changed_header.source_hash && + initial.fingerprint() != changed_header.fingerprint(), + "Nested gameplay header changes the complete source snapshot"); + + atomic_write(scripts / "main.lua", "return {}\n"); + atomic_write_json(project / "project.faset.json", + {{"format", "faset.project"}, + {"version", 1}, + {"scripting", {{"lua", {{"scripts", {"Scripts/main.lua"}}}}}}}); + auto lua = scripting::loadLuaProject(project); + const auto with_lua = editor::capture_build_inputs(config, lua); + atomic_write(scripts / "other.lua", "return {}\n"); + auto manifest = read_json(project / "project.faset.json"); + manifest["scripting"]["lua"]["scripts"].push_back("Scripts/other.lua"); + atomic_write_json(project / "project.faset.json", manifest); + lua = scripting::loadLuaProject(project); + const auto changed_declaration = editor::capture_build_inputs(config, lua); + check(with_lua.source_hash != changed_declaration.source_hash, + "Lua entry declaration changes the source snapshot"); + + const auto native = config.build_directory / "Debug"; + editor::ensure_native_toolchain_stamp(native, changed_declaration); + atomic_write(native / "sentinel", "keep\n"); + editor::ensure_native_toolchain_stamp(native, changed_declaration); + check(fs::exists(native / "sentinel"), "Unchanged toolchain preserves the native tree"); + atomic_write(compiler, "compiler-v2\n"); + const auto changed_tool = editor::capture_build_inputs(config, lua); + check(changed_tool.source_hash == changed_declaration.source_hash && + changed_tool.toolchain_hash != changed_declaration.toolchain_hash, + "Changing compiler bytes at the same path changes toolchain identity"); + editor::ensure_native_toolchain_stamp(native, changed_tool); + check(!fs::exists(native / "sentinel"), + "Changed toolchain invalidates only the generated native tree"); + + fs::create_directories(root / "outside"); + std::error_code link_error; + fs::create_directory_symlink(root / "outside", scripts / "linked", link_error); + if (!link_error) { + bool rejected = false; + try { + (void)editor::capture_build_inputs(config, lua); + } catch (const std::exception&) { + rejected = true; + } + check(rejected, "Source snapshot refuses a symlink escaping Scripts"); + } +} +} // namespace + +int main() { + const auto root = fs::temp_directory_path() / path_from_utf8("Faset build cache Café 世界 " + new_id()); + try { + run(root); + fs::remove_all(root); + std::cout << "Complete source and toolchain snapshot contracts passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + fs::remove_all(root); + return 1; + } +} diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp index ecb3acf..fabbbb2 100644 --- a/tests/build_schema_tests.cpp +++ b/tests/build_schema_tests.cpp @@ -142,6 +142,8 @@ int test_main(int argc, char** argv) { config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"}; editor::BuildService builds(config); builds.scaffold("Schema publication", 2); + atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp", + "#define BUILD_ONLY 1\n"); authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas()); const auto valid = manifest(); atomic_write_json(config.project_root / "schema-fixture.json", valid); @@ -161,6 +163,13 @@ int test_main(int argc, char** argv) { const auto previous_player = sha256_file(player); const auto previous_schema = read_text(schema); const auto previous_manifest = read_text(directory / "manifest.json"); + atomic_write(config.project_root / "mutate-cpp-header-during-build", "fixture\n"); + const auto raced_header = builds.wait(builds.start_build()); + check(raced_header.state == "failed" && read_text(last_build) == previous_pointer, + "Header changed during schema export cannot publish a mixed build"); + fs::remove(config.project_root / "mutate-cpp-header-during-build"); + atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp", + "#define BUILD_ONLY 1\n"); check(!first.result.at("lua_enabled").get() && !fs::exists(directory / "project.faset.json"), "C++-only build publishes no Lua sources or project manifest"); diff --git a/tests/build_schema_tool.cpp b/tests/build_schema_tool.cpp index b6d80ab..2c5184a 100644 --- a/tests/build_schema_tool.cpp +++ b/tests/build_schema_tool.cpp @@ -21,6 +21,8 @@ int tool_main(int argc, char** argv) { if (fs::exists("mutate-lua-snapshot")) atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n"); } + if (fs::exists("mutate-cpp-header-during-build")) + atomic_write("Scripts/Extensions/BuildOnly.hpp", "#define BUILD_ONLY 2\n"); atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json")); return 0; }