Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user