Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export

This commit is contained in:
Emil
2026-09-18 03:40:15 +03:00
parent 5c6b24d34d
commit 999686a896
125 changed files with 16086 additions and 1714 deletions
+747
View File
@@ -0,0 +1,747 @@
#include <atomic>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <faset/assets/asset_data.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.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) {
std::map<std::string, int> versions;
for (const auto* type : {"faset.transform", "faset.sprite", "faset.mesh", "faset.camera",
"faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"})
versions[type] = 1;
for (const auto& type : schema.value("types", Json::array())) {
auto id = type.at("id").get<std::string>();
if (versions.contains(id))
throw std::runtime_error("Gameplay schema duplicates a component type: " + id);
versions[id] = type.value("version", 1);
}
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>();
auto found = versions.find(id);
if (found == versions.end())
throw std::runtime_error("Cannot cook unresolved component type: " + id);
if (component.value("version", 1) != found->second)
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: " +
source.string());
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);
}
} // namespace
Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state},
{"stage", stage}, {"progress", progress}, {"log", log},
{"error", error}, {"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();
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 description = "$";
for (const auto& argument : arguments)
description += " " + Json(argument).dump();
description += '\n';
log(job, description);
Process process({std::move(arguments), cwd, {}});
std::string output;
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);
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)
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::AssetStore pipeline(config.cache_root);
for (const auto& id : asset_references(scene))
pipeline.load_asset(id);
}
Json build(Job& job, bool exporting = false) {
const auto& configuration = exporting ? config.export_configuration : config.configuration;
const auto native_directory = config.build_directory / configuration;
checkpoint(job, "Configuring C++ gameplay", .05);
if (!fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.cpp") ||
!fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.hpp"))
throw std::runtime_error("Project Scripts/Gameplay.cpp and Gameplay.hpp are required; "
"create a project scaffold first");
fs::create_directories(native_directory);
std::vector<std::string> arguments = {config.cmake,
"-S",
config.engine_root.string(),
"-B",
native_directory.string(),
"-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=" +
(config.project_root / "Scripts").string()};
bool compiler_overridden = false;
for (const auto& arg : config.configure_arguments)
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
compiler_overridden = true;
if (!compiler_overridden) {
#ifdef _WIN32
auto compiler = find_executable("clang-cl");
arguments.push_back("-DCMAKE_C_COMPILER=" + compiler.string());
arguments.push_back("-DCMAKE_CXX_COMPILER=" + compiler.string());
#else
arguments.push_back("-DCMAKE_C_COMPILER=" + find_executable("clang").string());
arguments.push_back("-DCMAKE_CXX_COMPILER=" + find_executable("clang++").string());
#endif
}
arguments.insert(arguments.end(), config.configure_arguments.begin(),
config.configure_arguments.end());
arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
run(job, std::move(arguments), config.project_root);
checkpoint(job, "Compiling and linking Player", .25);
run(job,
{config.cmake, "--build", native_directory.string(), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root);
checkpoint(job, "Exporting gameplay schema", .58);
auto player = build_executable(native_directory, configuration, "faset_player");
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter");
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";
run(job, {exporter.string(), "--output", schema_file.string()}, 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");
std::string fingerprint = sha256_file(player) + sha256_file(exporter) +
read_text(native_directory / "CMakeCache.txt");
for (const auto& file : {"Gameplay.cpp", "Gameplay.hpp"})
fingerprint += read_text(config.project_root / "Scripts" / file);
fingerprint = sha256(fingerprint);
schema["build_fingerprint"] = 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"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json manifest{{"format", "faset.build"},
{"version", 1},
{"id", job.status.id},
{"fingerprint", fingerprint},
{"configuration", configuration},
{"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68);
fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
return {{"generation", job.status.id},
{"directory", generation.string()},
{"build_directory", native_directory.string()},
{"configuration", configuration},
{"player", (generation / ("faset_player" + executable_suffix())).string()},
{"schema", (generation / "schema.json").string()},
{"fingerprint", fingerprint}};
} 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 = entry.path().extension().string();
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, {find_executable("ldd").string(), executable.string()}, 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 = Json::object();
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.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", (directory / "scene.fscene").string()},
{"directory", directory.string()}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
throw;
}
}
void package_notices(const fs::path& destination, const fs::path& native_directory) {
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 = entry.path().filename().string();
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 / 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);
}
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 = fs::path(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(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 = fs::path(built.at("directory").get<std::string>());
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"})
copy_required_file(build_directory / "shaders" / shader,
staging / "shaders" / shader);
for (const auto& entry : fs::directory_iterator(build_directory)) {
auto extension = entry.path().extension().string();
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", built.at("build_directory").get<std::string>());
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/, and Notices/ 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,
{(staging / ("faset_player" + executable_suffix())).string(), "--validate",
"--scene", (staging / "scene.fscene").string(), "--assets", staging.string()},
staging);
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", entry.path().lexically_relative(staging).generic_string()},
{"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")},
{"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", generation.string()},
{"executable", (generation / ("faset_player" + executable_suffix())).string()},
{"manifest", (generation / "manifest.json").string()},
{"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");
}
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
+157
View File
@@ -0,0 +1,157 @@
#include <algorithm>
#include <faset/authoring/templates.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/commands.hpp>
#include <set>
namespace faset::editor {
Json Commands::object_schema(Json properties, Json required) {
return {{"type", "object"},
{"properties", std::move(properties)},
{"required", std::move(required)},
{"additionalProperties", false}};
}
void Commands::add(std::string name, std::string description, Json schema, Handler handler,
bool read_only) {
require(!commands_.contains(name), "command.duplicate", "Command already registered: " + name);
Json descriptor = {{"name", name},
{"description", std::move(description)},
{"inputSchema", std::move(schema)},
{"annotations", {{"readOnlyHint", read_only}, {"openWorldHint", false}}}};
commands_.emplace(std::move(name), Command{std::move(descriptor), std::move(handler)});
}
void Commands::remove(const std::string& name) {
commands_.erase(name);
}
Json Commands::list() const {
Json result = Json::array();
for (const auto& [name, command] : commands_)
result.push_back(command.descriptor);
return result;
}
Json Commands::call(const std::string& name, const Json& arguments) {
const auto found = commands_.find(name);
require(found != commands_.end(), "command.unknown", "Unknown editor command: " + name);
require(arguments.is_object(), "arguments.object", "Tool arguments must be an object");
const auto& schema = found->second.descriptor["inputSchema"];
for (const auto& key : schema.value("required", Json::array()))
require(arguments.contains(key.get<std::string>()), "arguments.required",
"Missing argument: " + key.get<std::string>());
for (const auto& [key, value] : arguments.items()) {
require(schema["properties"].contains(key), "arguments.unknown",
"Unknown argument: " + key);
const auto& property = schema["properties"][key];
const auto type = property.value("type", std::string());
const bool valid =
type.empty() || (type == "string" && value.is_string()) ||
(type == "boolean" && value.is_boolean()) || (type == "number" && value.is_number()) ||
(type == "integer" && value.is_number_integer()) ||
(type == "object" && value.is_object()) || (type == "array" && value.is_array());
require(valid, "arguments.type", "Invalid type for argument: " + key);
if (property.contains("enum"))
require(std::find(property["enum"].begin(), property["enum"].end(), value) !=
property["enum"].end(),
"arguments.enum", "Unsupported value for argument: " + key);
if (property.contains("maximum") && value.is_number())
require(value.get<double>() <= property["maximum"].get<double>(), "arguments.maximum",
"Argument exceeds its maximum: " + key);
if (property.contains("minimum") && value.is_number())
require(value.get<double>() >= property["minimum"].get<double>(), "arguments.minimum",
"Argument is below its minimum: " + key);
}
try {
return found->second.handler(arguments);
} catch (const Json::exception& error) {
throw Error("arguments.invalid", "Invalid command data", {{"reason", error.what()}});
}
}
Json Commands::resolved_scene(const std::string& id) const {
const auto result = authoring::resolve_templates(
authoring_.query(id).at("scene"), authoring_.schemas(),
[&](const std::string& path) { return read_json(project_path(authoring_.root(), path)); });
return {{"scene", result.scene}, {"conflicts", result.conflicts}};
}
Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authoring) {
const Json text = {{"type", "string"}}, integer = {{"type", "integer"}, {"minimum", 0}},
boolean = {{"type", "boolean"}};
add(
"faset_documents",
"List open authoring documents and their revisions. This does not inspect a running game.",
object_schema(Json::object()),
[&](const Json&) { return Json{{"documents", authoring_.documents()}}; }, true);
add("faset_document_create",
"Create an unsaved 2D or 3D scene. Returns its persistent document ID and revision.",
object_schema({{"name", text}, {"dimension", integer}}, {"name", "dimension"}),
[&](const Json& args) { return authoring_.create(args.at("name"), args.at("dimension")); });
add("faset_document_open",
"Open a scene relative to the project. Set recover=true to load its saved recovery "
"journal.",
object_schema({{"path", text}, {"recover", boolean}}, {"path"}), [&](const Json& args) {
return authoring_.open(args.at("path").get<std::string>(),
args.value("recover", false));
});
add(
"faset_document_query",
"Read an authoring scene, persistent IDs, dirty state and revision. No Player or runtime "
"state is exposed.",
object_schema({{"document", text}}, {"document"}),
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
add("faset_document_save",
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
return authoring_.save(args.at("document"), args.value("path", std::string()));
});
add(
"faset_schema",
"Inspect registered component TypeIds, stable FieldIds, defaults and constraints.",
object_schema(Json::object()), [&](const Json&) { return authoring_.schemas().manifest(); },
true);
add("faset_scene_edit",
"Apply one atomic authoring batch with optimistic revision checking and one Undo step. "
"Operations: entity.create/rename/delete/duplicate/reparent; component.add/remove/set; "
"scene.rename; template.instance/override/revert/suppress/add/reparent. Use persistent IDs "
"from document_query and schema. An idempotency_key retries the same payload in this "
"session.",
object_schema({{"document", text},
{"revision", integer},
{"operations", {{"type", "array"}, {"items", {{"type", "object"}}}}},
{"idempotency_key", text}},
{"document", "revision", "operations"}),
[&](const Json& args) {
return authoring_.transact(args.at("document"), args.at("revision"),
args.at("operations"),
args.value("idempotency_key", std::string()));
});
add("faset_undo", "Undo one authoring transaction. Requires the current document revision.",
object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}),
[&](const Json& args) {
return authoring_.undo(args.at("document"), args.at("revision"));
});
add("faset_redo", "Redo one authoring transaction. Requires the current document revision.",
object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}),
[&](const Json& args) {
return authoring_.redo(args.at("document"), args.at("revision"));
});
add(
"faset_template_preview",
"Resolve authoring templates and report conflicts without changing source documents. This "
"is not a live game query.",
object_schema({{"document", text}}, {"document"}),
[&](const Json& args) { return resolved_scene(args.at("document")); }, true);
add("faset_recovery_restore",
"Restore a recovery journal, including an unsaved new scene. If the document is already "
"open, pass its current revision. External file changes are never overwritten.",
object_schema({{"document", text}, {"revision", integer}}, {"document"}),
[&](const Json& args) {
return authoring_.recover(
args.at("document"),
args.contains("revision")
? std::optional<std::uint64_t>(args.at("revision").get<std::uint64_t>())
: std::nullopt);
});
add(
"faset_recovery_list", "List document recovery records in this project.",
object_schema(Json::object()),
[&](const Json&) { return Json{{"recovery", authoring_.recovery_documents()}}; }, true);
}
} // namespace faset::editor
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
#include <faset/editor/mcp.hpp>
#include <iostream>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <cerrno>
#include <poll.h>
#include <unistd.h>
#endif
namespace faset::editor {
namespace {
Json rpc_error(Json id, int code, std::string message, Json data = Json::object()) {
return {
{"jsonrpc", "2.0"},
{"id", id},
{"error", {{"code", code}, {"message", std::move(message)}, {"data", std::move(data)}}}};
}
} // namespace
Json McpServer::parse_error() const {
return rpc_error(nullptr, -32700, "Parse error");
}
std::optional<Json> McpServer::handle(const Json& message) {
if (!message.is_object() || !message.contains("jsonrpc") || message["jsonrpc"] != "2.0" ||
!message.contains("method") || !message["method"].is_string())
return rpc_error(nullptr, -32600, "Invalid Request");
const auto method = message.at("method").get<std::string>();
if (!message.contains("id")) {
if (method == "notifications/initialized" && initialized_)
ready_ = true;
return std::nullopt;
}
const Json id = message.at("id");
if (!(id.is_string() || id.is_number_integer()))
return rpc_error(nullptr, -32600, "Request ID must be a string or integer");
const auto params = message.value("params", Json::object());
if (!params.is_object())
return rpc_error(id, -32602, "Invalid params");
auto result = [&](Json value) {
return Json{{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(value)}};
};
if (method == "ping")
return result(Json::object());
if (method == "initialize") {
if (initialized_)
return rpc_error(id, -32600, "Session already initialized");
if (!params.contains("protocolVersion") || !params["protocolVersion"].is_string())
return rpc_error(id, -32602, "protocolVersion is required");
initialized_ = true;
return result(
{{"protocolVersion", "2025-06-18"},
{"serverInfo", {{"name", "faset-editor"}, {"version", FASET_VERSION}}},
{"capabilities", {{"tools", Json::object()}, {"resources", Json::object()}}},
{"instructions",
"Faset tools edit project documents and manage editor jobs. Query revisions before "
"writes. Player/runtime world access is intentionally unavailable."}});
}
if (!ready_)
return rpc_error(id, -32002, "Initialize the session before using editor tools");
if (method == "tools/list")
return result({{"tools", commands_.list()}});
if (method == "tools/call") {
if (!params.contains("name") || !params["name"].is_string())
return rpc_error(id, -32602, "Tool name is required");
try {
auto value =
commands_.call(params.at("name"), params.value("arguments", Json::object()));
Json content = Json::array();
if (value.is_object() && value.contains("image_base64")) {
content.push_back(
{{"type", "image"},
{"data", value.at("image_base64")},
{"mimeType", value.value("mimeType", std::string("image/png"))}});
value.erase("image_base64");
}
content.push_back({{"type", "text"}, {"text", value.dump()}});
return result({{"content", content}, {"structuredContent", value}, {"isError", false}});
} catch (const Error& error) {
const auto value = Json{{"error", error.json()}};
return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})},
{"structuredContent", value},
{"isError", true}});
} catch (const std::exception& error) {
const auto value =
Json{{"error", {{"code", "editor.failure"}, {"message", error.what()}}}};
return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})},
{"structuredContent", value},
{"isError", true}});
}
}
if (method == "resources/list")
return result({{"resources", Json::array({{{"uri", "faset://schema"},
{"name", "Component schema"},
{"mimeType", "application/json"}},
{{"uri", "faset://documents"},
{"name", "Open authoring documents"},
{"mimeType", "application/json"}}})}});
if (method == "resources/read") {
if (!params.contains("uri") || !params["uri"].is_string())
return rpc_error(id, -32602, "Resource URI must be a string");
const auto uri = params.at("uri").get<std::string>();
Json value;
if (uri == "faset://schema")
value = commands_.authoring().schemas().manifest();
else if (uri == "faset://documents")
value = commands_.authoring().documents();
else
return rpc_error(id, -32602, "Unknown resource URI");
return result({{"contents", Json::array({{{"uri", uri},
{"mimeType", "application/json"},
{"text", value.dump()}}})}});
}
return rpc_error(id, -32601, "Method not found");
}
std::vector<std::string> StdioTransport::poll() {
std::vector<std::string> lines;
if (closed_)
return lines;
char bytes[65536];
std::size_t count = 0;
#ifdef _WIN32
const auto input = GetStdHandle(STD_INPUT_HANDLE);
const auto type = GetFileType(input);
DWORD available = 0;
if (type == FILE_TYPE_PIPE) {
if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) {
closed_ = true;
return lines;
}
} else if (type == FILE_TYPE_DISK)
available = sizeof(bytes);
else
return lines;
if (available) {
DWORD read = 0;
if (!ReadFile(input, bytes, std::min<DWORD>(available, sizeof(bytes)), &read, nullptr) ||
read == 0)
closed_ = true;
count = read;
}
#else
pollfd input{STDIN_FILENO, POLLIN, 0};
if (::poll(&input, 1, 0) > 0 && (input.revents & (POLLIN | POLLHUP))) {
const auto read = ::read(STDIN_FILENO, bytes, sizeof(bytes));
if (read > 0)
count = static_cast<std::size_t>(read);
else if (read == 0)
closed_ = true;
else if (errno != EINTR && errno != EAGAIN)
closed_ = true;
}
#endif
buffer_.append(bytes, count);
require(buffer_.size() <= 8 * 1024 * 1024, "mcp.message_size", "MCP input exceeds 8 MiB");
std::size_t newline = 0;
while ((newline = buffer_.find('\n')) != std::string::npos) {
auto line = buffer_.substr(0, newline);
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!line.empty())
lines.push_back(std::move(line));
buffer_.erase(0, newline + 1);
}
if (closed_ && !buffer_.empty()) {
lines.push_back(std::move(buffer_));
buffer_.clear();
}
return lines;
}
void StdioTransport::send(const Json& value) {
std::cout << value.dump() << '\n';
std::cout.flush();
}
} // namespace faset::editor
+358
View File
@@ -0,0 +1,358 @@
#include <algorithm>
#include <cctype>
#include <faset/core/io.hpp>
#include <faset/editor/plugin_api.h>
#include <faset/editor/plugins.hpp>
#include <set>
#include <thread>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <dlfcn.h>
#endif
namespace faset::editor {
namespace {
void append(void* context, const char* bytes, std::uint64_t size) {
auto& output = *static_cast<std::string*>(context);
require(size <= 8 * 1024 * 1024 && output.size() + size <= 8 * 1024 * 1024,
"plugin.output_limit", "Plugin response exceeds 8 MiB");
output.append(bytes, static_cast<std::size_t>(size));
}
Json response(const std::string& bytes) {
auto value = Json::parse(bytes);
require(value.is_object(), "plugin.response", "Plugin response must be a JSON object");
return value;
}
bool identifier(const std::string& value) {
return !value.empty() && value.size() <= 128 &&
std::all_of(value.begin(), value.end(), [](unsigned char c) {
return c < 128 && (std::isalnum(c) || c == '.' || c == '_' || c == '-');
});
}
std::string prefix(std::string id) {
for (auto& c : id)
if (c == '.' || c == '-')
c = '_';
return "plugin_" + id + "_";
}
} // namespace
struct PluginManager::Impl {
struct Registration {
Json descriptor;
FasetCommand callback = nullptr;
void* user = nullptr;
};
struct Module {
Impl* owner = nullptr;
Json manifest;
std::filesystem::path directory;
std::vector<Registration> commands;
Json panels = Json::array();
std::vector<std::string> installed;
FasetEditorHost host{};
FasetEditorPlugin plugin{};
void* library = nullptr;
bool ready = false;
std::string failure;
~Module() {
ready = false;
for (const auto& name : installed)
owner->commands.remove(name);
if (plugin.shutdown)
try {
plugin.shutdown(plugin.user);
} catch (...) { /* Plugin contract forbids exceptions. */
}
#ifdef _WIN32
if (library)
FreeLibrary(static_cast<HMODULE>(library));
#else
if (library)
dlclose(library);
#endif
}
std::string id() const {
return manifest.at("id");
}
void check_thread() const {
require(std::this_thread::get_id() == owner->thread, "plugin.thread",
"Editor SDK calls must run on the Editor thread");
}
};
Commands& commands;
Logger logger;
std::thread::id thread = std::this_thread::get_id();
std::vector<std::unique_ptr<Module>> modules;
Json records = Json::array();
bool attempted = false;
unsigned call_depth = 0;
Impl(Commands& value, Logger output) : commands(value), logger(std::move(output)) {}
~Impl() {
while (!modules.empty())
modules.pop_back();
}
static int command(void* context, const char* descriptor, FasetCommand callback, void* user) {
auto& module = *static_cast<Module*>(context);
try {
module.check_thread();
require(!module.ready, "plugin.registration_closed", "Registrations are startup-only");
require(callback, "plugin.callback", "Command callback is missing");
const auto value = Json::parse(descriptor);
const auto name = value.at("name").get<std::string>();
require(name.starts_with(prefix(module.id())) && identifier(name),
"plugin.command_owner", "Plugin command must use its module prefix");
require(value.at("description").is_string() &&
value.at("inputSchema").at("type") == "object" &&
value.at("inputSchema").at("properties").is_object(),
"plugin.command_schema", "Command requires an object input schema");
for (const auto& prior : module.commands)
require(prior.descriptor.at("name") != name, "plugin.command_duplicate",
"Duplicate plugin command");
module.commands.push_back({value, callback, user});
return 0;
} catch (const std::exception& error) {
module.failure = error.what();
return 1;
}
}
static int panel(void* context, const char* descriptor) {
auto& module = *static_cast<Module*>(context);
try {
module.check_thread();
require(!module.ready, "plugin.registration_closed", "Registrations are startup-only");
auto value = Json::parse(descriptor);
require(value.at("id").get<std::string>().starts_with(module.id() + "."),
"plugin.panel_owner", "Panel ID must belong to its module");
require(value.at("title").is_string() && value.at("command").is_string(),
"plugin.panel", "Panel needs a title and command");
if (!value.contains("arguments"))
value["arguments"] = Json::object();
require(value["arguments"].is_object(), "plugin.panel",
"Panel arguments must be an object");
for (const auto& previous : module.panels)
require(previous["id"] != value["id"], "plugin.panel_duplicate",
"Duplicate panel ID");
value["owner"] = module.id();
module.panels.push_back(std::move(value));
return 0;
} catch (const std::exception& error) {
module.failure = error.what();
return 1;
}
}
static int invoke(void* context, const char* name, const char* arguments, FasetWrite write,
void* receiver) {
auto& module = *static_cast<Module*>(context);
bool entered = false;
try {
module.check_thread();
require(module.ready, "plugin.not_ready",
"Editor commands become available after plugin startup");
require(module.owner->call_depth < 32, "plugin.recursion",
"Plugin command recursion limit exceeded");
++module.owner->call_depth;
entered = true;
const auto output = module.owner->commands.call(name, Json::parse(arguments)).dump();
--module.owner->call_depth;
entered = false;
write(receiver, output.data(), output.size());
return 0;
} catch (const std::exception& error) {
if (entered)
--module.owner->call_depth;
const auto* known = dynamic_cast<const Error*>(&error);
const auto output =
(known ? known->json()
: Json{{"code", "plugin.command"}, {"message", error.what()}})
.dump();
if (write)
try {
write(receiver, output.data(), output.size());
} catch (...) {
}
return 1;
}
}
static void log(void* context, const char* text) {
auto& module = *static_cast<Module*>(context);
try {
module.check_thread();
module.owner->logger(module.id() + ": " + text);
} catch (...) {
}
}
void activate(const Json& manifest, const std::filesystem::path& directory) {
auto module = std::make_unique<Module>();
module->owner = this;
module->manifest = manifest;
module->directory = directory;
const auto path = project_path(directory, manifest.at("library").get<std::string>());
require(std::filesystem::is_regular_file(path), "plugin.library",
"Plugin library is missing");
#ifdef _WIN32
module->library =
LoadLibraryExW(path.c_str(), nullptr,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
auto entry = module->library
? reinterpret_cast<FasetPluginEntry>(GetProcAddress(
static_cast<HMODULE>(module->library), "faset_editor_plugin"))
: nullptr;
#else
module->library = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
const auto error = module->library ? nullptr : dlerror();
require(module->library, "plugin.load", error ? error : "Cannot load plugin library");
auto entry =
reinterpret_cast<FasetPluginEntry>(dlsym(module->library, "faset_editor_plugin"));
#endif
require(module->library && entry, "plugin.entry",
"Library does not export faset_editor_plugin");
module->host = {FASET_EDITOR_API_VERSION,
sizeof(FasetEditorHost),
FASET_EDITOR_SDK_FINGERPRINT,
module.get(),
command,
panel,
invoke,
log};
require(entry(&module->host, &module->plugin) == 0, "plugin.startup",
"Plugin startup failed");
require(module->failure.empty(), "plugin.registration", module->failure);
require(module->plugin.api_version == FASET_EDITOR_API_VERSION &&
module->plugin.struct_size == sizeof(FasetEditorPlugin),
"plugin.api", "Plugin returned an incompatible API");
require(module->plugin.build_fingerprint &&
std::string(module->plugin.build_fingerprint) == FASET_EDITOR_SDK_FINGERPRINT,
"plugin.build", "Plugin binary does not match this Editor SDK build");
for (const auto& value : module->panels) {
const auto name = value.at("command").get<std::string>();
require(std::any_of(module->commands.begin(), module->commands.end(),
[&](const auto& reg) { return reg.descriptor.at("name") == name; }),
"plugin.panel_command", "Panel command must be registered by its owner");
}
for (const auto& registration : module->commands) {
const auto& descriptor = registration.descriptor;
const auto name = descriptor.at("name").get<std::string>();
commands.add(
name, descriptor.at("description"), descriptor.at("inputSchema"),
[registration](const Json& arguments) {
std::string output;
const auto input = arguments.dump();
const int result =
registration.callback(registration.user, input.c_str(), append, &output);
const auto value = response(output);
if (result != 0)
throw Error(value.value("code", std::string("plugin.failed")),
value.value("message", std::string("Plugin command failed")),
value);
return value;
},
descriptor.value("read_only", false));
module->installed.push_back(name);
}
module->ready = true;
logger("Loaded editor plugin: " + module->id());
modules.push_back(std::move(module));
}
};
PluginManager::PluginManager(Commands& commands, Logger logger)
: impl_(std::make_unique<Impl>(commands, std::move(logger))) {}
PluginManager::~PluginManager() = default;
std::string PluginManager::fingerprint() {
return FASET_EDITOR_SDK_FINGERPRINT;
}
Json PluginManager::status() const {
return impl_->records;
}
Json PluginManager::panels() const {
Json result = Json::array();
for (const auto& module : impl_->modules)
for (const auto& panel : module->panels)
result.push_back(panel);
return result;
}
void PluginManager::load(const std::filesystem::path& directory) {
require(!impl_->attempted, "plugin.restart_required",
"Plugin discovery runs once; restart the Editor after changing packages");
impl_->attempted = true;
if (!std::filesystem::exists(directory))
return;
struct Source {
Json manifest;
std::filesystem::path directory;
};
std::map<std::string, Source> sources;
std::set<std::string> invalid;
auto failure = [&](const std::string& id, const std::string& message) {
invalid.insert(id);
impl_->records.push_back({{"id", id}, {"state", "failed"}, {"message", message}});
impl_->logger("Plugin " + id + ": " + message);
};
for (const auto& entry : std::filesystem::recursive_directory_iterator(directory))
if (entry.is_regular_file() &&
entry.path().filename().string().ends_with(".faset-plugin.json")) {
std::string id = entry.path().filename().string();
try {
const auto manifest = read_json(entry.path());
id = manifest.at("id");
require(identifier(id), "plugin.id", "Invalid module ID");
require(!sources.contains(id), "plugin.duplicate", "Duplicate module ID");
require(manifest.at("format") == "faset.editor_plugin" &&
manifest.at("version") == 1 && manifest.at("kind") == "editor",
"plugin.manifest", "Unsupported editor plugin manifest");
require(manifest.at("module_version").is_string() &&
manifest.at("dependencies").is_array(),
"plugin.manifest", "Plugin needs version and dependency list");
require(manifest.at("api_version") == FASET_EDITOR_API_VERSION &&
manifest.at("build_fingerprint") == fingerprint(),
"plugin.compatibility",
"Plugin manifest does not match this Editor SDK; rebuild it");
project_path(entry.path().parent_path(), manifest.at("library").get<std::string>());
sources.emplace(id, Source{manifest, entry.path().parent_path()});
} catch (const std::exception& error) {
failure(id, error.what());
}
}
std::map<std::string, int> colors;
std::vector<std::string> order;
std::function<void(const std::string&)> visit = [&](const std::string& id) {
require(sources.contains(id) && !invalid.contains(id), "plugin.dependency",
"Missing or invalid dependency: " + id);
require(colors[id] != 1, "plugin.cycle", "Plugin dependency cycle at " + id);
if (colors[id] == 2)
return;
colors[id] = 1;
for (const auto& dependency : sources.at(id).manifest.at("dependencies")) {
const std::string required = dependency.at("id");
visit(required);
require(sources.at(required).manifest.at("module_version") == dependency.at("version"),
"plugin.dependency_version", "Dependency version mismatch: " + required);
}
colors[id] = 2;
order.push_back(id);
};
for (const auto& [id, source] : sources)
try {
visit(id);
} catch (const std::exception& error) {
failure(id, error.what());
}
std::set<std::string> loaded;
for (const auto& id : order)
if (!invalid.contains(id))
try {
const auto& source = sources.at(id);
for (const auto& dependency : source.manifest.at("dependencies"))
require(loaded.contains(dependency.at("id").get<std::string>()),
"plugin.dependency_failed", "A required plugin failed to load");
impl_->activate(source.manifest, source.directory);
loaded.insert(id);
impl_->records.push_back({{"id", id},
{"version", source.manifest.at("module_version")},
{"state", "loaded"}});
} catch (const std::exception& error) {
failure(id, error.what());
}
}
} // namespace faset::editor
+358
View File
@@ -0,0 +1,358 @@
#include <algorithm>
#include <chrono>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
namespace faset::editor {
namespace {
std::string executable_name(const std::string& name) {
#ifdef _WIN32
return name + ".exe";
#else
return name;
#endif
}
BuildConfig build_config(const SessionConfig& config) {
BuildConfig result;
result.project_root = config.project_root;
result.engine_root = config.engine_root;
result.build_directory = config.project_root / ".faset/build";
result.cache_root = config.project_root / ".faset/cache";
return result;
}
Json resolved_or_throw(Commands& commands, const std::string& id) {
const auto resolved = commands.resolved_scene(id);
if (!resolved.at("conflicts").empty())
throw Error("template.conflicts", "Resolve template conflicts before Play or export",
{{"conflicts", resolved.at("conflicts")}});
return resolved.at("scene");
}
} // namespace
struct Session::ImportTask {
std::string id;
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
mutable std::mutex mutex;
std::string state = "queued", error;
Json result = Json::object();
Json json() const {
std::lock_guard lock(mutex);
const auto progress = job->progress();
return {{"id", id},
{"kind", "import"},
{"state", state},
{"stage", progress.stage},
{"progress", progress.fraction},
{"error", error},
{"result", result}};
}
};
Session::Session(SessionConfig config)
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
register_commands();
plugins_ = std::make_unique<PluginManager>(
commands_, [this](std::string message) { log(std::move(message)); });
plugins_->load(config_.project_root / "Plugins");
const auto schema = config_.project_root / ".faset/schema.json";
if (std::filesystem::exists(schema))
try {
load_schema(schema);
} catch (const std::exception& error) {
log(std::string("Schema load failed: ") + error.what());
}
}
Session::~Session() {
for (const auto& [id, task] : imports_)
task->job->cancel();
workers_.clear();
stop_player();
}
void Session::log(std::string value) {
if (value.empty())
return;
logs_.push_back(std::move(value));
if (logs_.size() > 1000)
logs_.erase(logs_.begin(), logs_.begin() + 100);
}
Json Session::project() const {
const auto path = config_.project_root / "project.faset.json";
if (std::filesystem::exists(path))
return read_json(path);
return {{"format", "faset.project"},
{"version", 1},
{"name", config_.project_root.filename().string()},
{"dimension", 3}};
}
void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension);
log("Created project: " + name);
}
Json Session::assets_list() const {
Json list = Json::array();
const auto directory = assets_.cache_root() / "assets";
if (std::filesystem::exists(directory))
for (const auto& entry : std::filesystem::directory_iterator(directory))
if (entry.is_directory()) {
try {
const auto id = entry.path().filename().string();
const auto manifest = assets_.current_manifest(id);
list.push_back({{"id", id}, {"manifest", manifest}});
} catch (const std::exception& error) {
list.push_back(
{{"id", entry.path().filename().string()}, {"error", error.what()}});
}
}
return {{"assets", list}};
}
Json Session::jobs() const {
Json list = Json::array();
for (const auto& item : builds_.jobs())
list.push_back(item.json());
for (const auto& [id, task] : imports_)
list.push_back(task->json());
return {{"jobs", list}};
}
Json Session::job(const std::string& id) const {
if (imports_.contains(id))
return imports_.at(id)->json();
return builds_.job(id).json();
}
void Session::load_schema(const std::filesystem::path& path) {
const auto value = read_json(path);
authoring_.replace_external_schemas(value);
const auto output = config_.project_root / ".faset/schema.json";
if (std::filesystem::weakly_canonical(path) != std::filesystem::weakly_canonical(output))
atomic_write_json(output, value);
log("Gameplay schema loaded");
}
void Session::launch_player(Json scene, const std::filesystem::path& executable) {
require(std::filesystem::is_regular_file(executable), "play.missing_player",
"Build the Player before starting Play");
const auto directory = config_.project_root / ".faset/play" / new_id();
std::filesystem::create_directories(directory);
const auto snapshot = directory / "scene.fscene";
write_cooked_scene(snapshot, scene);
control_path_ = directory / "control.json";
control_sequence_ = 0;
ProcessOptions options;
options.arguments = {
executable.string(), "--scene", snapshot.string(), "--assets",
assets_.cache_root().string(), "--control", control_path_.string()};
options.working_directory = config_.project_root;
player_ = std::make_unique<Process>(options);
log("Play started in a separate Player process");
}
void Session::stop_player() {
if (!pending_play_job_.empty()) {
builds_.cancel(pending_play_job_);
pending_play_job_.clear();
pending_play_scene_ = nullptr;
}
if (player_) {
player_->cancel();
const auto result = player_->poll();
log(result.output);
player_.reset();
log("Play stopped; authoring scene unchanged");
}
}
void Session::poll() {
if (player_) {
const auto result = player_->poll();
log(result.output);
if (!result.running) {
log("Player exited with code " + std::to_string(result.exit_code.value_or(-1)));
player_.reset();
}
}
for (const auto& value : builds_.jobs()) {
if (observed_jobs_[value.id] == value.state)
continue;
observed_jobs_[value.id] = value.state;
if (value.state == "failed")
log(value.kind + " failed: " + value.error);
if (value.state == "succeeded") {
log(value.kind + " completed");
if (value.result.contains("schema"))
try {
load_schema(value.result.at("schema").get<std::string>());
} catch (const std::exception& error) {
log(std::string("Schema update failed: ") + error.what());
}
}
if (value.id == pending_play_job_ && value.finished()) {
pending_play_job_.clear();
if (value.state == "succeeded")
try {
const auto executable =
value.result.value("player", (builds_.config().build_directory /
executable_name("faset_player"))
.string());
launch_player(pending_play_scene_, executable);
} catch (const std::exception& error) {
log(std::string("Play failed: ") + error.what());
}
pending_play_scene_ = nullptr;
}
}
for (const auto& [id, task] : imports_) {
const auto value = task->json();
const auto status = value.at("state").get<std::string>();
if (observed_jobs_[id] == status)
continue;
observed_jobs_[id] = status;
if (status == "succeeded")
log("Asset import completed: " + value["result"].value("asset_id", std::string()));
if (status == "failed" || status == "conflict")
log("Asset import " + status + ": " + value.value("error", std::string()));
}
}
void Session::register_commands() {
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
auto schema = [](Json properties, Json required = Json::array()) {
return Commands::object_schema(std::move(properties), std::move(required));
};
commands_.add(
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
schema(Json::object()),
[&](const Json&) {
bool screenshot = false;
for (const auto& command : commands_.list())
if (command.at("name") == "faset_editor_capture")
screenshot = true;
return Json{{"authoring", true},
{"build", true},
{"import", true},
{"plugins", true},
{"viewport_capture", screenshot},
{"runtime_entity_access", false},
{"protocol", "2025-06-18"}};
},
true);
commands_.add(
"faset_plugins", "Inspect startup-loaded Editor plugins and exact SDK compatibility.",
schema(Json::object()),
[&](const Json&) {
return Json{{"sdk_fingerprint", PluginManager::fingerprint()},
{"plugins", plugins_->status()},
{"panels", plugins_->panels()}};
},
true);
commands_.add(
"faset_project", "Read the authoring project's settings.", schema(Json::object()),
[&](const Json&) { return project(); }, true);
commands_.add(
"faset_assets", "List imported asset manifests and resource identities.",
schema(Json::object()), [&](const Json&) { return assets_list(); }, true);
commands_.add("faset_import",
"Import GLB/glTF or a Blender export manifest relative to this project. Returns "
"a cancellable job ID; failure retains the last successful generation.",
schema({{"path", text},
{"settings", {{"type", "object"}}},
{"allow_removed_outputs", boolean}},
{"path"}),
[&](const Json& args) {
assets::ImportRequest request;
request.source =
project_path(config_.project_root, args.at("path").get<std::string>());
request.settings = args.value("settings", Json(nullptr));
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
auto task = std::make_shared<ImportTask>();
task->id = "import-" + new_id();
imports_[task->id] = task;
workers_.emplace_back([this, task, request] {
{
std::lock_guard lock(task->mutex);
task->state = "running";
}
try {
const auto result = assets_.import_asset(request, *task->job);
std::lock_guard lock(task->mutex);
task->state =
result.status == assets::ImportStatus::succeeded ? "succeeded"
: result.status == assets::ImportStatus::cancelled ? "cancelled"
: result.status == assets::ImportStatus::conflict ? "conflict"
: "failed";
task->result = {{"asset_id", result.asset_id},
{"generation", result.generation},
{"diagnostics", result.diagnostics},
{"cache_hit", result.cache_hit},
{"manifest", result.manifest}};
for (const auto& message : result.diagnostics)
task->error += message + "\n";
} catch (const std::exception& error) {
std::lock_guard lock(task->mutex);
task->state = "failed";
task->error = error.what();
}
});
return Json{{"job", task->id}};
});
commands_.add("faset_build",
"Incrementally compile C++ gameplay and export its metadata in separate native "
"processes. Returns a job ID.",
schema(Json::object()),
[&](const Json&) { return Json{{"job", builds_.start_build()}}; });
commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.",
schema({{"document", text}, {"output", text}}, {"document", "output"}),
[&](const Json& args) {
return Json{{"job", builds_.start_export(
resolved_or_throw(commands_, args.at("document")),
project_path(config_.project_root,
args.at("output").get<std::string>()))}};
});
commands_.add(
"faset_jobs", "List editor import/build/export jobs and their progress.",
schema(Json::object()), [&](const Json&) { return jobs(); }, true);
commands_.add(
"faset_job", "Read an editor job's progress, result and diagnostics.",
schema({{"id", text}}, {"id"}), [&](const Json& args) { return job(args.at("id")); }, true);
commands_.add("faset_job_cancel",
"Cancel an editor job. Cancellation is separate from authoring Undo.",
schema({{"id", text}}, {"id"}), [&](const Json& args) {
const auto id = args.at("id").get<std::string>();
if (imports_.contains(id))
imports_.at(id)->job->cancel();
else
builds_.cancel(id);
return Json{{"cancel_requested", true}};
});
commands_.add("faset_play",
"Build gameplay, then start a separate Player from an immutable snapshot of the "
"current authoring document. Returns the build job ID.",
schema({{"document", text}}, {"document"}), [&](const Json& args) {
stop_player();
pending_play_scene_ = resolved_or_throw(commands_, args.at("document"));
pending_play_job_ = builds_.start_build();
return Json{{"job", pending_play_job_}, {"play_pending", true}};
});
commands_.add(
"faset_stop",
"Stop editor Play or cancel its pending build. Does not modify the authoring scene.",
schema(Json::object()), [&](const Json&) {
stop_player();
return Json{{"stopped", true}};
});
commands_.add("faset_play_control",
"Pause, resume, or single-step the Editor's Player session. No game entities or "
"state are exposed.",
schema({{"command", {{"type", "string"}, {"enum", {"pause", "resume", "step"}}}}},
{"command"}),
[&](const Json& args) {
require(bool(player_), "play.not_running", "Player is not running");
const auto command = args.at("command").get<std::string>();
require(command == "pause" || command == "resume" || command == "step",
"play.command", "Unknown Play control");
atomic_write_json(control_path_,
{{"sequence", ++control_sequence_}, {"command", command}});
return Json{{"queued", true}};
});
commands_.add(
"faset_editor_logs",
"Read compiler, importer and process diagnostics collected by this Editor session.",
schema(Json::object()), [&](const Json&) { return Json{{"logs", logs_}}; }, true);
}
} // namespace faset::editor