Capture complete gameplay and toolchain build inputs
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,154 @@
|
||||
#include <faset/editor/build_cache.hpp>
|
||||
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <cstdlib>
|
||||
#include <stdexcept>
|
||||
|
||||
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
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/editor/build_cache.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <fstream>
|
||||
@@ -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<std::string> 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",
|
||||
|
||||
+2
-14
@@ -2,6 +2,7 @@
|
||||
#include <chrono>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/build_cache.hpp>
|
||||
#include <faset/editor/session.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
|
||||
@@ -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<std::filesystem::path> 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 {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/build_cache.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <iostream>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<bool>() &&
|
||||
!fs::exists(directory / "project.faset.json"),
|
||||
"C++-only build publishes no Lua sources or project manifest");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user