Files
Faset_Engine/src/editor/build_service.cpp
T

1138 lines
58 KiB
C++

#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/authoring/service.hpp>
#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_diagnostics.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <set>
#include <stdexcept>
#include <thread>
namespace faset::editor {
namespace fs = std::filesystem;
namespace {
struct Cancelled {};
constexpr std::size_t max_log_bytes = 8 * 1024 * 1024;
void validate_scene(const Json& scene) {
if (!scene.is_object() || scene.value("format", "") != "faset.scene" ||
scene.value("version", 0) != 1 || !scene.contains("entities") ||
!scene["entities"].is_array())
throw std::runtime_error("Cook requires a version 1 Faset scene");
if (!scene.value("instances", Json::array()).empty())
throw std::runtime_error("Resolve template instances before cooking");
int dimension = scene.value("dimension", 0);
if (dimension != 2 && dimension != 3)
throw std::runtime_error("Scene dimension must be 2 or 3");
}
void validate_component_types(const Json& scene, const Json& schema) {
const auto registry =
schema.is_null() ? authoring::builtin_schemas() : authoring::gameplay_schemas(schema);
for (const auto& entity : scene.at("entities"))
for (const auto& component : entity.value("components", Json::array())) {
const auto id = component.at("type").get<std::string>();
if (!registry.contains(id))
throw std::runtime_error("Cannot cook unresolved component type: " + id);
if (component.value("version", 1) != registry.schema(id).value("version", 1))
throw std::runtime_error("Migrate component '" + id +
"' to the current gameplay schema before cooking");
}
}
std::set<std::string> asset_references(const Json& scene) {
std::set<std::string> result;
for (const auto& entity : scene.at("entities"))
for (const auto& component : entity.value("components", Json::array())) {
const auto type = component.value("type", "");
if (type != "faset.mesh" && type != "faset.sprite")
continue;
const auto fields = component.value("fields", Json::object());
auto asset = fields.value(type == "faset.sprite" ? "texture" : "asset", "");
if (asset.empty() || asset == "builtin:cube" || asset == "builtin:plane")
continue;
if (asset.starts_with("builtin:"))
throw std::runtime_error("Unknown builtin asset: " + asset);
result.insert(asset.substr(0, asset.find('#')));
}
return result;
}
void copy_required_file(const fs::path& source, const fs::path& target) {
if (!fs::is_regular_file(source) || fs::is_symlink(source))
throw std::runtime_error("Required package file missing or not a regular file: " +
path_to_utf8(source));
fs::create_directories(target.parent_path());
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
}
std::string executable_suffix() {
#ifdef _WIN32
return ".exe";
#else
return "";
#endif
}
fs::path build_executable(const fs::path& build, const std::string& configuration,
const std::string& target) {
for (const auto& root :
{build, build / configuration, build / "bin", build / "bin" / configuration}) {
auto file = root / (target + executable_suffix());
if (fs::is_regular_file(file))
return file;
}
throw std::runtime_error("Build did not produce " + target);
}
Json starter_scene(const std::string& name, int dimension, std::string_view language) {
const auto schemas = authoring::builtin_schemas();
auto scene = authoring::make_scene(name + " — Starter", dimension);
scene["simulation"] = authoring::default_simulation_settings();
auto add_component = [&](Json& entity, std::string type, Json fields) {
entity["components"].push_back({{"id", new_id()},
{"type", std::move(type)},
{"version", 1},
{"fields", std::move(fields)}});
};
auto ground = authoring::make_entity(schemas, "Ground");
auto& ground_pose = ground["components"][0]["fields"];
ground_pose["position"] = {0, -1, 0};
ground_pose["scale"] = dimension == 2 ? Json::array({12, 1, 1}) : Json::array({12, 1, 12});
if (dimension == 2) {
add_component(ground, "faset.sprite", {{"size", {1, 1}},
{"color", {0.20, 0.25, 0.31, 1}}});
} else {
add_component(ground, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.20, 0.25, 0.31, 1}}});
}
auto ground_body = schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) +
"d");
ground_body["body_type"] = "static";
add_component(ground, "faset.rigid_body_" + std::to_string(dimension) + "d",
std::move(ground_body));
scene["entities"].push_back(std::move(ground));
auto actor = authoring::make_entity(schemas, "Player");
actor["components"][0]["fields"]["position"] = {0, 1, 0};
if (dimension == 2) {
add_component(actor, "faset.sprite", {{"size", {0.8, 0.8}},
{"color", {0.25, 0.72, 0.85, 1}}});
} else {
actor["components"][0]["fields"]["scale"] = {0.8, 0.8, 0.8};
add_component(actor, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.25, 0.72, 0.85, 1}}});
}
add_component(actor, "faset.rigid_body_" + std::to_string(dimension) + "d",
schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) + "d"));
add_component(actor, language == "lua" ? "starter.player" : "gameplay.character",
{{"speed", 4.0}, {"jump_speed", 5.0}});
scene["entities"].push_back(std::move(actor));
if (dimension == 3) {
auto camera = authoring::make_entity(schemas, "Camera");
camera["components"][0]["fields"]["position"] = {0, 2.5, 8};
camera["components"][0]["fields"]["rotation"] = {-0.18, 0, 0};
add_component(camera, "faset.camera", schemas.default_fields("faset.camera"));
scene["entities"].push_back(std::move(camera));
auto sun = authoring::make_entity(schemas, "Sun");
add_component(sun, "faset.light", schemas.default_fields("faset.light"));
scene["entities"].push_back(std::move(sun));
}
// Validate every built-in component now; the behavior component is validated by
// SchemaExporter from the selected C++/Lua module during the first build.
auto builtins = scene;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [](const Json& component) {
return !component.at("type").get<std::string>().starts_with("faset.");
});
}
authoring::validate_scene(builtins, schemas);
return scene;
}
struct StarterCreateLock {
fs::path path;
explicit StarterCreateLock(const fs::path& root) : path(root / ".faset/starter-create.lock") {
if (!fs::create_directory(path))
throw std::runtime_error("Another project creation is in progress; remove a stale " +
path_to_utf8(path) + " only after closing that Editor");
}
~StarterCreateLock() {
std::error_code ignored;
fs::remove_all(path, ignored);
}
StarterCreateLock(const StarterCreateLock&) = delete;
StarterCreateLock& operator=(const StarterCreateLock&) = delete;
};
void require_new_starter_directory(const fs::path& root) {
// BuildService creates .faset/cache in its constructor. Any other content is
// user-owned, including a previous starter, and must be left intact.
for (const auto& entry : fs::directory_iterator(root)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
throw std::runtime_error("Create requires a new or empty project directory");
for (const auto& internal : fs::directory_iterator(entry.path())) {
if (internal.path().filename() == "starter-create.lock" &&
internal.is_directory() && !internal.is_symlink())
continue;
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
throw std::runtime_error("Create requires a new or empty project directory");
}
}
}
} // namespace
Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state},
{"stage", stage}, {"progress", progress}, {"log", log},
{"error", error}, {"diagnostics", diagnostics}, {"result", result}};
}
void write_cooked_scene(const fs::path& path, const Json& scene) {
validate_scene(scene);
const auto payload = Json::to_cbor(scene);
std::string bytes = "FASETSCN";
bytes.reserve(20 + payload.size());
for (unsigned i = 0; i < 4; ++i)
bytes.push_back(static_cast<char>((std::uint32_t(1) >> (i * 8)) & 255));
for (unsigned i = 0; i < 8; ++i)
bytes.push_back(static_cast<char>((std::uint64_t(payload.size()) >> (i * 8)) & 255));
bytes.append(reinterpret_cast<const char*>(payload.data()), payload.size());
atomic_write(path, bytes);
}
struct BuildService::Impl {
struct Job {
mutable std::mutex mutex;
JobStatus status;
std::atomic<bool> cancelled{};
std::condition_variable finished;
Json scene;
Json asset_manifests = Json::object();
scripting::LuaProject lua;
fs::path output;
};
BuildConfig config;
mutable std::mutex mutex;
std::condition_variable condition;
std::map<std::string, std::shared_ptr<Job>> jobs;
std::deque<std::shared_ptr<Job>> queue;
bool stopping{};
std::thread worker;
explicit Impl(BuildConfig c) : config(std::move(c)) {
if (config.project_root.empty() || config.engine_root.empty())
throw std::invalid_argument("Build service requires project and engine directories");
config.project_root = fs::absolute(config.project_root);
config.engine_root = fs::absolute(config.engine_root);
if (config.cache_root.empty())
config.cache_root = config.project_root / ".faset" / "cache";
else
config.cache_root = fs::absolute(config.cache_root);
if (config.build_directory.empty())
config.build_directory = config.project_root / ".faset" / "build";
else
config.build_directory = fs::absolute(config.build_directory);
if (config.configuration != "Debug" && config.configuration != "Release" &&
config.configuration != "RelWithDebInfo")
throw std::invalid_argument("Unsupported build configuration");
if (config.export_configuration != "Release" &&
config.export_configuration != "RelWithDebInfo")
throw std::invalid_argument("Export configuration must be Release or RelWithDebInfo");
fs::create_directories(config.project_root);
fs::create_directories(config.cache_root);
worker = std::thread([this] { work(); });
}
~Impl() {
{
std::lock_guard lock(mutex);
stopping = true;
for (auto& [_, job] : jobs)
job->cancelled = true;
}
condition.notify_all();
if (worker.joinable())
worker.join();
}
std::shared_ptr<Job> lookup(const std::string& id) const {
std::lock_guard lock(mutex);
auto it = jobs.find(id);
if (it == jobs.end())
throw std::out_of_range("Unknown build job: " + id);
return it->second;
}
std::string enqueue(std::string kind, Json scene = {}, fs::path output = {}) {
auto job = std::make_shared<Job>();
job->status.id = new_id();
job->status.kind = std::move(kind);
job->scene = std::move(scene);
job->output = std::move(output);
{
std::lock_guard lock(mutex);
if (stopping)
throw std::runtime_error("Build service is stopping");
jobs.emplace(job->status.id, job);
queue.push_back(job);
}
condition.notify_all();
return job->status.id;
}
void checkpoint(Job& job, std::string stage, double progress) {
if (job.cancelled)
throw Cancelled{};
std::lock_guard lock(job.mutex);
job.status.stage = std::move(stage);
job.status.progress = progress;
}
void log(Job& job, std::string_view text) {
std::lock_guard lock(job.mutex);
job.status.log.append(text);
if (job.status.log.size() > max_log_bytes)
job.status.log.erase(0, job.status.log.size() - max_log_bytes);
}
std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
if (job.cancelled)
throw Cancelled{};
std::string phase;
std::size_t diagnostics_before;
{
std::lock_guard lock(job.mutex);
phase = job.status.stage;
diagnostics_before = job.status.diagnostics.size();
}
std::string description = "$";
for (const auto& argument : arguments)
description += " " + Json(argument).dump();
description += '\n';
log(job, description);
Process process({std::move(arguments), cwd, {}});
std::string output;
std::string pending;
const auto collect_diagnostics = [&](std::string_view chunk, bool final) {
pending.append(chunk);
std::size_t newline;
while ((newline = pending.find('\n')) != std::string::npos) {
const auto line = pending.substr(0, newline + 1);
pending.erase(0, newline + 1);
const auto found = parse_build_diagnostics(line, phase, config.project_root);
if (found.empty())
continue;
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
}
if (final && !pending.empty()) {
const auto found = parse_build_diagnostics(pending, phase, config.project_root);
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
pending.clear();
} else if (pending.size() > 8192)
pending.erase(0, pending.size() - 8192);
};
while (true) {
if (job.cancelled) {
process.cancel();
auto final = process.poll();
log(job, final.output);
throw Cancelled{};
}
auto poll = process.poll();
log(job, poll.output);
collect_diagnostics(poll.output, !poll.running);
output += poll.output;
if (output.size() > max_log_bytes)
output.erase(0, output.size() - max_log_bytes);
if (!poll.running) {
if (poll.exit_code.value_or(1) != 0) {
std::lock_guard lock(job.mutex);
bool has_error = false;
for (std::size_t index = diagnostics_before;
index < job.status.diagnostics.size(); ++index)
if (job.status.diagnostics[index].value("severity", "") == "error")
has_error = true;
if (!has_error && job.status.diagnostics.size() < 200)
job.status.diagnostics.push_back(
{{"severity", "error"},
{"phase", phase},
{"message", "Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log"}});
throw std::runtime_error("Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log");
}
return output;
}
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
}
void validate_assets(const Json& scene) {
assets::AssetPipeline pipeline(config.cache_root);
for (const auto& id : asset_references(scene)) {
pipeline.load_asset(id);
const auto freshness = pipeline.freshness(id);
if (freshness.value("state", std::string("unavailable")) != "current")
throw std::runtime_error("Asset " + id +
" is stale or unavailable. Reimport before cooking or "
"exporting. Details: " +
freshness.dump());
}
}
Json build(Job& job, bool exporting = false) {
const auto started = std::chrono::steady_clock::now();
const auto milliseconds = [](auto from, auto to) {
return std::chrono::duration_cast<std::chrono::milliseconds>(to - from).count();
};
const auto& configuration = exporting ? config.export_configuration : config.configuration;
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 staged_scripts = stage_gameplay_sources(config, inputs, job.lua, job.status.id);
const auto cpp = staged_scripts / "Gameplay.cpp";
const auto hpp = staged_scripts / "Gameplay.hpp";
const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp);
if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled()))
throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, "
"or Lua entry scripts declared in project.faset.json");
ensure_native_toolchain_stamp(config, native_directory, inputs);
std::vector<std::string> arguments = {config.cmake,
"-S",
path_to_utf8(config.engine_root),
"-B",
path_to_utf8(native_directory),
"-G",
config.generator,
"-DCMAKE_BUILD_TYPE=" + configuration,
"-DBUILD_TESTING=OFF",
"-DFASET_BUILD_EDITOR=OFF",
"-DFASET_BUILD_RENDERER=ON",
"-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
path_to_utf8(staged_scripts)};
if (!configured_cmake_value(config, "CMAKE_C_COMPILER")) {
#ifdef _WIN32
auto compiler = find_executable("clang-cl");
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
#else
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang")));
#endif
}
if (!configured_cmake_value(config, "CMAKE_CXX_COMPILER")) {
#ifdef _WIN32
arguments.push_back("-DCMAKE_CXX_COMPILER=" +
path_to_utf8(find_executable("clang-cl")));
#else
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
#endif
}
for (const auto& argument : config.configure_arguments) {
const auto equal = argument.find('=');
const auto colon = argument.find(':', 2);
const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2);
if (key == "CMAKE_C_COMPILER" || key == "CMAKE_CXX_COMPILER" ||
key == "SLANGC_EXECUTABLE" || key == "CMAKE_TOOLCHAIN_FILE") {
const auto value = path_from_utf8(argument.substr(equal + 1));
if (value.has_parent_path() && value.is_relative()) {
arguments.push_back(argument.substr(0, equal + 1) +
path_to_utf8((config.project_root / value).lexically_normal()));
continue;
}
}
arguments.push_back(argument);
}
arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
// Project declarations, not a stale cache or a user-supplied override, determine
// whether the packaged game has a Lua VM linked into it.
arguments.push_back(std::string("-DFASET_ENABLE_LUA=") +
(job.lua.enabled() ? "ON" : "OFF"));
run(job, std::move(arguments), config.project_root);
const auto configured = std::chrono::steady_clock::now();
checkpoint(job, "Compiling and linking Player", .25);
run(job,
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root);
const auto compiled = std::chrono::steady_clock::now();
auto player = build_executable(native_directory, configuration, "faset_player");
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter");
const auto package_key =
build_package_key(inputs, native_directory, configuration, player, exporter);
const auto source_unchanged = [&] {
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");
};
const auto result_for = [&](const std::string& id, const std::string& fingerprint,
bool reused) -> Json {
const auto directory = config.cache_root / "builds" / id;
const auto finished = std::chrono::steady_clock::now();
return {{"generation", id},
{"directory", path_to_utf8(directory)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", path_to_utf8(directory / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(directory / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint},
{"schema_cache_hit", reused},
{"generation_reused", reused},
{"phase_times_ms",
{{"configure", milliseconds(started, configured)},
{"native_build", milliseconds(configured, compiled)},
{"schema_package", reused ? 0 : milliseconds(compiled, finished)},
{"total", milliseconds(started, finished)}}}};
};
const auto pointer_file = config.cache_root / "last_build.json";
if (fs::is_regular_file(pointer_file))
try {
const auto pointer = read_json(pointer_file);
const auto id = pointer.at("generation").get<std::string>();
const auto candidate =
project_path(config.cache_root, fs::path("builds") / id);
if (validate_build_generation(candidate, package_key)) {
source_unchanged();
checkpoint(job, "Reusing verified build generation", .68);
const auto manifest = read_json(candidate / "manifest.json");
log(job, "Verified schema/package cache hit: " + id + "\n");
return result_for(id, manifest.at("fingerprint").get<std::string>(), true);
}
} catch (const std::exception&) {
// A bad pointer or old/corrupt generation is a cache miss.
}
checkpoint(job, "Exporting gameplay schema", .58);
const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id);
const auto generation = config.cache_root / "builds" / job.status.id;
fs::create_directories(staging);
try {
const auto schema_file = staging / "schema.json";
std::vector<std::string> export_arguments = {path_to_utf8(exporter), "--output",
path_to_utf8(schema_file)};
if (job.lua.enabled()) {
scripting::writeLuaSources(job.lua, staging);
atomic_write_json(staging / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"scripting", {{"lua", {{"scripts", job.lua.scripts}}}}}});
export_arguments.insert(export_arguments.end(),
{"--project", path_to_utf8(staging)});
}
run(job, std::move(export_arguments), config.project_root);
auto schema = read_json(schema_file);
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
!schema.contains("types") || !schema.at("types").is_array())
throw std::runtime_error("SchemaExporter returned an invalid manifest");
// Validate the complete metadata before publishing either the schema or
// its Player generation. Session uses this same authoring contract.
(void)authoring::gameplay_schemas(schema);
const auto fingerprint = package_key;
schema["build_fingerprint"] = fingerprint;
if (job.lua.enabled())
schema["lua_fingerprint"] = job.lua.fingerprint;
atomic_write_json(schema_file, schema);
copy_required_file(player, staging / ("faset_player" + executable_suffix()));
copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix()));
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json", "gpuVertexMain.spv",
"gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv",
"gpuPostCullMain.spv", "gpuVertexMain.reflection.json",
"gpuShadowMain.reflection.json", "gpuCullMain.reflection.json",
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
if (entry.is_symlink())
throw std::runtime_error("Build generation contains a symlink");
if (entry.is_regular_file())
files.push_back(
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
{"sha256", sha256_file(entry.path())},
{"size", entry.file_size()}});
}
Json manifest{{"format", "faset.build"},
{"version", 2},
{"id", job.status.id},
{"fingerprint", fingerprint},
{"package_key", package_key},
{"configuration", configuration},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"},
{"files", files}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68);
if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot changed during schema export");
source_unchanged();
if (!validate_build_generation(staging, package_key))
throw std::runtime_error("Build generation failed final integrity validation");
fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
return result_for(job.status.id, fingerprint, false);
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
throw;
}
}
void copy_runtime_libraries(Job& job, const fs::path& executable, const fs::path& destination,
const fs::path& native_directory,
const std::string& configuration) {
#ifdef _WIN32
// Libraries produced by the selected toolchain are copied beside the executable.
// System DLLs (including Vulkan) remain platform prerequisites.
for (const auto& root :
{executable.parent_path(), native_directory, native_directory / configuration})
if (fs::is_directory(root))
for (const auto& entry : fs::directory_iterator(root)) {
auto extension = path_to_utf8(entry.path().extension());
for (auto& c : extension)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (entry.is_regular_file() && extension == ".dll")
copy_required_file(entry.path(), destination / entry.path().filename());
}
// The actual packaged executable is launched before publication to reject missing imports.
(void)job;
#else
const auto output =
run(job, {path_to_utf8(find_executable("ldd")), path_to_utf8(executable)},
config.project_root);
if (output.find("not found") != std::string::npos)
throw std::runtime_error("Player has unresolved shared library dependencies");
// SDL/physics/gameplay are linked statically. glibc/libstdc++/Vulkan are the host baseline.
// Refuse an unexpected private DSO rather than silently publish a non-portable package.
std::size_t begin{};
while (begin < output.size()) {
auto end = output.find('\n', begin);
auto line = output.substr(begin, end == std::string::npos ? end : end - begin);
auto arrow = line.find("=> ");
if (arrow != std::string::npos) {
auto name = line.substr(0, arrow);
name.erase(0, name.find_first_not_of(" \t"));
auto path_start = arrow + 3;
auto path_end = line.find(" (", path_start);
auto path = line.substr(path_start, path_end - path_start);
if (!path.empty() && path[0] == '/' && !path.starts_with("/lib/") &&
!path.starts_with("/lib64/") && !path.starts_with("/usr/lib/") &&
!path.starts_with("/usr/lib64/"))
throw std::runtime_error(
"Private shared library needs an explicit package rule: " + path);
}
if (end == std::string::npos)
break;
begin = end + 1;
}
(void)destination;
(void)native_directory;
(void)configuration;
#endif
}
Json cook(Job& job) {
checkpoint(job, "Validating scene and assets", .1);
validate_scene(job.scene);
validate_assets(job.scene);
Json schema;
if (fs::exists(config.cache_root / "last_build.json")) {
auto pointer = read_json(config.cache_root / "last_build.json");
auto schema_path = project_path(
config.cache_root,
fs::path("builds") / pointer.at("generation").get<std::string>() / "schema.json");
schema = read_json(schema_path);
}
validate_component_types(job.scene, schema);
auto source_hash = sha256(job.scene.dump());
auto schema_fingerprint =
schema.is_null() ? std::string("builtin-v1")
: schema.value("build_fingerprint", std::string("builtin-v1"));
auto digest = sha256(source_hash + schema_fingerprint);
auto directory = config.cache_root / "cooked" / digest;
auto staging = config.cache_root / "cooked" / (".staging-" + job.status.id);
fs::create_directories(staging);
try {
checkpoint(job, "Writing cooked scene", .6);
write_cooked_scene(staging / "scene.fscene", job.scene);
Json manifest{{"format", "faset.cooked-scene"},
{"version", 1},
{"scene", job.scene.value("id", "")},
{"source_hash", source_hash},
{"schema_fingerprint", schema_fingerprint},
{"sha256", sha256_file(staging / "scene.fscene")}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing cooked scene", .9);
if (fs::exists(directory)) {
if (read_json(directory / "manifest.json") != manifest ||
sha256_file(directory / "scene.fscene") !=
manifest.at("sha256").get<std::string>())
throw std::runtime_error("Existing cooked generation is corrupt");
fs::remove_all(staging);
} else
fs::rename(staging, directory);
atomic_write_json(config.cache_root / "last_cook.json", {{"generation", digest}});
return {{"generation", digest},
{"scene", path_to_utf8(directory / "scene.fscene")},
{"directory", path_to_utf8(directory)}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
throw;
}
}
void package_notices(const fs::path& destination, const fs::path& native_directory,
bool lua_enabled) {
fs::create_directories(destination);
auto lock = read_json(config.engine_root / "dependencies.lock.json");
const std::vector<std::string> runtime_dependencies = {"sdl3", "entt", "box2d",
"box3d", "json", "stb"};
Json used = Json::object();
for (const auto& name : runtime_dependencies) {
std::vector<fs::path> roots = {native_directory / "_deps" / (name + "-src"),
config.engine_root / ".cache" / "deps-src" / name};
bool copied{};
for (const auto& source : roots) {
if (!fs::is_directory(source))
continue;
for (const auto& entry : fs::directory_iterator(source)) {
if (!entry.is_regular_file())
continue;
auto filename = path_to_utf8(entry.path().filename());
std::string upper = filename;
for (auto& c : upper)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") ||
upper.starts_with("NOTICE")) {
copy_required_file(entry.path(),
destination / name / entry.path().filename());
copied = true;
}
}
if (copied)
break;
}
if (!copied)
throw std::runtime_error("Cannot package required license notices for " + name);
used[name] = lock.at("dependencies").at(name);
}
if (lua_enabled) {
copy_required_file(config.engine_root / "docs" / "licenses" / "Lua.txt",
destination / "lua" / "LICENSE.txt");
used["lua"] = lock.at("dependencies").at("lua");
}
atomic_write_json(destination / "dependencies.json", used);
if (fs::is_regular_file(config.engine_root / "LICENSE"))
copy_required_file(config.engine_root / "LICENSE", destination / "Faset-LICENSE");
atomic_write(destination / "Faset-NOTICE.txt",
"Faset Engine\nhttps://github.com/emil28092005/Faset_Engine\nSee the source "
"repository for the current license status of Faset's own code.\nThird-party "
"license texts are included in the adjacent directories.\n");
}
void package_assets(Job& job, const fs::path& destination) {
assets::AssetStore packaged(destination);
for (const auto& id : asset_references(job.scene)) {
if (job.cancelled)
throw Cancelled{};
auto manifest = job.asset_manifests.at(id);
auto generation = manifest.at("generation").get<std::string>();
auto source_directory = project_path(config.cache_root, fs::path("assets") / id /
"generations" / generation);
auto target = destination / "assets" / id / "generations" / generation;
for (const auto& file : manifest.at("files")) {
auto relative = path_from_utf8(file.at("path").get<std::string>());
copy_required_file(project_path(source_directory, relative),
project_path(target, relative));
}
manifest["source"] = "<cooked>";
if (manifest.contains("payload_source"))
manifest["payload_source"] = "<cooked>";
atomic_write_json(target / "manifest.json", manifest);
atomic_write_json(
destination / "assets" / id / "current.json",
{{"schema_version", 1}, {"generation", generation}, {"source", "<cooked>"}});
packaged.load_asset(id);
}
}
Json export_game(Job& job) {
validate_scene(job.scene);
validate_assets(job.scene);
assets::AssetStore source(config.cache_root);
for (const auto& id : asset_references(job.scene))
job.asset_manifests[id] = source.current_manifest(id);
const auto built = build(job, true);
validate_component_types(job.scene,
read_json(path_from_utf8(built.at("schema").get<std::string>())));
auto output = fs::absolute(job.output);
if (output.empty())
throw std::runtime_error("An export destination is required");
fs::create_directories(output / "generations");
auto staging = output / (".staging-" + job.status.id);
auto generation = output / "generations" / job.status.id;
fs::create_directories(staging);
try {
checkpoint(job, "Cooking export snapshot", .72);
write_cooked_scene(staging / "scene.fscene", job.scene);
auto build_directory = path_from_utf8(built.at("directory").get<std::string>());
if (job.lua.enabled()) {
// Never read live Scripts files for a published game: schemas, source,
// and fingerprint all originate in the same validated build snapshot.
const auto captured = scripting::loadLuaProject(build_directory);
if (captured.fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot is corrupt");
scripting::writeLuaSources(captured, staging);
copy_required_file(build_directory / "project.faset.json",
staging / "project.faset.json");
}
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json", "gpuVertexMain.spv",
"gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv",
"gpuPostCullMain.spv", "gpuVertexMain.reflection.json",
"gpuShadowMain.reflection.json", "gpuCullMain.reflection.json",
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(build_directory / "shaders" / shader,
staging / "shaders" / shader);
for (const auto& entry : fs::directory_iterator(build_directory)) {
auto extension = path_to_utf8(entry.path().extension());
for (auto& c : extension)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (entry.is_regular_file() && extension == ".dll")
copy_required_file(entry.path(), staging / entry.path().filename());
}
checkpoint(job, "Packaging assets and notices", .80);
package_assets(job, staging);
package_notices(staging / "Notices",
path_from_utf8(built.at("build_directory").get<std::string>()),
job.lua.enabled());
atomic_write(
staging / "README.txt",
"Run faset_player" + executable_suffix() +
" to start this game.\nThe executable loads scene.fscene and assets beside "
"it.\nKeep shaders/, assets/, Notices/, and any Scripts/ and "
"project.faset.json "
"with the executable.\nA compatible "
"Vulkan 1.3 driver and the supported OS runtime are required.\n");
#ifdef _WIN32
atomic_write(staging / "Windows-Runtime.txt",
"Install the Microsoft Visual C++ x64 Redistributable if Windows reports "
"a missing MSVCP140 or VCRUNTIME140 DLL.\nOfficial installer: "
"https://aka.ms/vc14/vc_redist.x64.exe\nThe Vulkan loader and GPU driver "
"must also be installed.\n");
#endif
checkpoint(job, "Validating packaged Player", .90);
auto validation_output =
run(job,
{path_to_utf8(staging / ("faset_player" + executable_suffix())), "--validate",
"--scene", path_to_utf8(staging / "scene.fscene"), "--assets",
path_to_utf8(staging)},
staging);
if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Packaged Lua snapshot changed during validation");
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
if (entry.is_symlink())
throw std::runtime_error("Export contains a symlink");
if (entry.is_regular_file())
files.push_back(
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
{"sha256", sha256_file(entry.path())},
{"size", entry.file_size()}});
}
Json manifest{{"format", "faset.export"},
{"version", 1},
{"generation", job.status.id},
{"build_fingerprint", built.at("fingerprint")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"scene_hash", sha256(job.scene.dump())},
{"asset_generations", Json::object()},
{"configuration", built.at("configuration")},
{"executable", "faset_player" + executable_suffix()},
{"files", files}};
manifest["units"] = {
{"distance", "metre"}, {"angle", "radian"}, {"coordinates", "right-handed Y-up"}};
manifest["simulation"] = {{"fixed_delta", 1.0 / 60.0},
{"max_catch_up_ticks", 4},
{"physics_substeps", 4},
{"gravity", {0, -9.81, 0}}};
if (job.scene.contains("simulation"))
manifest["simulation"].update(job.scene.at("simulation"));
manifest["renderer_profile"] = {
{"api", "Vulkan 1.3"},
{"required_features", {"dynamicRendering", "synchronization2"}},
{"materials", {"base-color factor and texture", "metallic and roughness factors"}},
{"texture_sampling", "linear clamp, one mip level"},
{"shadow_map", {{"resolution", 1024}, {"world_extent", 40}}},
{"unsupported_material_features",
{"normal maps", "metallic-roughness maps", "emissive and occlusion maps",
"alpha mode selection", "unlit mode", "per-material face culling"}}};
manifest["validation_log"] = validation_output;
for (const auto& [id, asset] : job.asset_manifests.items())
manifest["asset_generations"][id] = asset.at("generation");
#ifdef _WIN32
manifest["platform"] = "windows";
manifest["prerequisites"] = {
"Windows x64", "Vulkan 1.3 driver",
"Microsoft Visual C++ x64 Redistributable (Visual Studio 2022 or newer)"};
#else
manifest["platform"] = "linux";
manifest["prerequisites"] = {"Linux x86_64", "Vulkan 1.3 driver",
"Compatible glibc and libstdc++ runtime"};
#endif
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing export generation", .98);
fs::rename(staging, generation);
atomic_write_json(output / "current.json",
{{"format", "faset.export-pointer"},
{"version", 1},
{"generation", job.status.id},
{"directory", "generations/" + job.status.id}});
return {
{"directory", path_to_utf8(generation)},
{"executable", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
{"manifest", path_to_utf8(generation / "manifest.json")},
{"generation", job.status.id},
{"build", built},
{"schema", built.at("schema")},
{"player", built.at("player")},
{"build_directory", built.at("build_directory")},
{"configuration", built.at("configuration")}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
throw;
}
}
void work() {
while (true) {
std::shared_ptr<Job> job;
{
std::unique_lock lock(mutex);
condition.wait(lock, [&] { return stopping || !queue.empty(); });
if (queue.empty()) {
if (stopping)
return;
continue;
}
job = queue.front();
queue.pop_front();
}
{
std::lock_guard lock(job->mutex);
job->status.state = "running";
}
try {
if (job->cancelled)
throw Cancelled{};
Json result;
if (job->status.kind == "build")
result = build(*job);
else if (job->status.kind == "cook")
result = cook(*job);
else
result = export_game(*job);
std::lock_guard lock(job->mutex);
job->status.state = "succeeded";
job->status.stage = "Complete";
job->status.progress = 1;
job->status.result = std::move(result);
} catch (const Cancelled&) {
std::lock_guard lock(job->mutex);
job->status.state = "cancelled";
job->status.stage = "Cancelled";
job->status.error =
"Job cancelled; previous published generations remain available";
} catch (const std::exception& error) {
std::lock_guard lock(job->mutex);
job->status.state = "failed";
job->status.stage = "Failed";
job->status.error = error.what();
}
job->finished.notify_all();
condition.notify_all();
}
}
};
BuildService::BuildService(BuildConfig config) : impl_(std::make_unique<Impl>(std::move(config))) {}
BuildService::~BuildService() = default;
const BuildConfig& BuildService::config() const {
return impl_->config;
}
void BuildService::scaffold(const std::string& name, int dimension) {
if (name.empty() || (dimension != 2 && dimension != 3))
throw std::invalid_argument("Project name and dimension 2 or 3 required");
const auto& c = impl_->config;
fs::create_directories(c.project_root / "Scripts");
fs::create_directories(c.project_root / "Scenes");
fs::create_directories(c.project_root / "Assets");
for (const auto* file : {"Gameplay.cpp", "Gameplay.hpp"}) {
auto target = c.project_root / "Scripts" / file;
if (!fs::exists(target)) {
auto source = c.engine_root / "tools" / "project_templates" / file;
copy_required_file(source, target);
}
}
auto project = c.project_root / "project.faset.json";
if (!fs::exists(project))
atomic_write_json(project, {{"format", "faset.project"},
{"version", 1},
{"id", new_id()},
{"name", name},
{"dimension", dimension},
{"start_scene", "Scenes/main.scene.json"}});
if (!fs::exists(c.project_root / ".gitignore"))
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n");
}
void BuildService::scaffold(const std::string& name, int dimension,
std::string_view language) {
if (name.empty() || (dimension != 2 && dimension != 3) ||
(language != "cpp" && language != "lua"))
throw std::invalid_argument("Starter requires a name, 2D/3D and cpp/lua language");
const auto& c = impl_->config;
StarterCreateLock lock(c.project_root);
require_new_starter_directory(c.project_root);
const auto scene = starter_scene(name, dimension, language);
const auto source = c.engine_root / "tools/project_templates" /
(language == "lua" ? "lua-main.lua" : "Gameplay.cpp");
const auto module = read_text(source);
const auto header = language == "cpp"
? read_text(c.engine_root / "tools/project_templates/Gameplay.hpp")
: std::string();
const auto lua_annotations = language == "lua"
? read_text(c.engine_root / "tools/lua/faset.lua")
: std::string();
const auto lua_config = language == "lua"
? read_json(c.engine_root / "tools/lua/luarc.json")
: Json();
const auto lua_scripts_config =
language == "lua" ? read_json(c.engine_root / "tools/lua/luarc-scripts.json") : Json();
const auto stage = lock.path / "staged";
fs::create_directories(stage / "Scripts");
fs::create_directories(stage / "Scenes");
fs::create_directories(stage / "Assets");
if (language == "cpp") {
atomic_write(stage / "Scripts/Gameplay.cpp", module);
atomic_write(stage / "Scripts/Gameplay.hpp", header);
} else {
atomic_write(stage / "Scripts/main.lua", module);
atomic_write(stage / ".faset/lua/faset.lua", lua_annotations);
atomic_write_json(stage / ".luarc.json", lua_config);
atomic_write_json(stage / "Scripts/.luarc.json", lua_scripts_config);
}
atomic_write_json(stage / "Scenes/main.scene.json", scene);
Json project = {{"format", "faset.project"},
{"version", 1},
{"id", new_id()},
{"name", name},
{"dimension", dimension},
{"start_scene", "Scenes/main.scene.json"}};
if (language == "lua")
project["scripting"] = {{"lua", {{"scripts", Json::array({"Scripts/main.lua"})}}}};
atomic_write_json(stage / "project.faset.json", project);
atomic_write(stage / ".gitignore",
".faset/*\n!.faset/lua/\n.faset/lua/*\n!.faset/lua/faset.lua\nExports/\n");
std::vector<std::pair<fs::path, std::string>> created;
auto publish = [&](const fs::path& relative) {
const auto source = stage / relative;
const auto target = c.project_root / relative;
fs::create_directories(target.parent_path());
if (!fs::copy_file(source, target, fs::copy_options::none))
throw std::runtime_error("Starter destination appeared during creation: " +
path_to_utf8(target));
created.emplace_back(target, sha256_file(source));
};
try {
publish(".gitignore");
if (language == "cpp") {
publish("Scripts/Gameplay.cpp");
publish("Scripts/Gameplay.hpp");
} else {
publish("Scripts/main.lua");
publish(".faset/lua/faset.lua");
publish(".luarc.json");
publish("Scripts/.luarc.json");
}
publish("Scenes/main.scene.json");
fs::create_directory(c.project_root / "Assets");
// The manifest is the final commit marker: an interrupted create has no
// valid project record and never replaces an existing project file.
publish("project.faset.json");
} catch (...) {
for (auto it = created.rbegin(); it != created.rend(); ++it) {
try {
std::error_code ignored;
if (fs::is_regular_file(it->first, ignored) &&
sha256_file(it->first) == it->second)
fs::remove(it->first, ignored);
} catch (...) { /* Preserve the original create failure. */
}
}
for (const auto& relative : {"Assets", "Scenes", "Scripts", ".faset/lua"}) {
std::error_code ignored;
fs::remove(c.project_root / relative, ignored); // Empty directories only.
}
throw;
}
}
std::string BuildService::start_build() {
return impl_->enqueue("build");
}
std::string BuildService::start_cook(Json scene) {
return impl_->enqueue("cook", std::move(scene));
}
std::string BuildService::start_export(Json scene, const fs::path& output) {
if (output.empty())
throw std::invalid_argument("Export output directory is required");
return impl_->enqueue("export", std::move(scene), output);
}
JobStatus BuildService::job(const std::string& id) const {
auto value = impl_->lookup(id);
std::lock_guard lock(value->mutex);
return value->status;
}
std::vector<JobStatus> BuildService::jobs() const {
std::vector<std::shared_ptr<Impl::Job>> values;
{
std::lock_guard lock(impl_->mutex);
for (auto& [_, value] : impl_->jobs)
values.push_back(value);
}
std::vector<JobStatus> result;
for (auto& value : values) {
std::lock_guard lock(value->mutex);
result.push_back(value->status);
}
return result;
}
void BuildService::cancel(const std::string& id) {
impl_->lookup(id)->cancelled = true;
impl_->condition.notify_all();
}
JobStatus BuildService::wait(const std::string& id) {
auto value = impl_->lookup(id);
std::unique_lock lock(value->mutex);
value->finished.wait(lock, [&] { return value->status.finished(); });
return value->status;
}
} // namespace faset::editor