Checkpoint 3: deliver playable samples and complete native authoring workflows

This commit is contained in:
Emil
2026-09-18 04:29:41 +03:00
parent fad5eb4e55
commit d834cfad67
92 changed files with 8335 additions and 353 deletions
+6 -2
View File
@@ -306,7 +306,9 @@ struct BuildService::Impl {
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"})
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json manifest{{"format", "faset.build"},
@@ -520,7 +522,9 @@ struct BuildService::Impl {
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"})
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json"})
copy_required_file(build_directory / "shaders" / shader,
staging / "shaders" / shader);
for (const auto& entry : fs::directory_iterator(build_directory)) {
+36 -3
View File
@@ -67,8 +67,13 @@ Json Commands::call(const std::string& name, const Json& arguments) {
}
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)); });
authoring_.query(id).at("scene"), authoring_.schemas(), [&](const std::string& path) {
const auto relative = std::filesystem::path(path).lexically_normal();
for (const auto& document : authoring_.documents())
if (document.at("path") == relative.generic_string())
return authoring_.query(document.at("id")).at("scene");
return read_json(project_path(authoring_.root(), relative));
});
return {{"scene", result.scene}, {"conflicts", result.conflicts}};
}
Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authoring) {
@@ -109,7 +114,10 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
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 "
"scene.rename/simulation; "
"template.instance/override/revert/suppress/restore/add/addition_set/reparent/remove/"
"source_set. Use "
"persistent IDs "
"from document_query and schema. An idempotency_key retries the same payload in this "
"session.",
object_schema({{"document", text},
@@ -138,6 +146,31 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
"is not a live game query.",
object_schema({{"document", text}}, {"document"}),
[&](const Json& args) { return resolved_scene(args.at("document")); }, true);
add(
"faset_simulation_get",
"Read scene simulation settings with defaults: fixed_delta seconds, max_catch_up_ticks, "
"physics_substeps and gravity.",
object_schema({{"document", text}}, {"document"}),
[&](const Json& args) {
const auto document = authoring_.query(args.at("document"));
auto settings = authoring::default_simulation_settings();
settings.update(document.at("scene").value("simulation", Json::object()));
return Json{{"document", document.at("id")},
{"revision", document.at("revision")},
{"settings", settings}};
},
true);
add("faset_simulation_set",
"Update scene simulation settings as one Undo transaction. Values apply when the Player "
"next starts.",
object_schema(
{{"document", text}, {"revision", integer}, {"settings", {{"type", "object"}}}},
{"document", "revision", "settings"}),
[&](const Json& args) {
return authoring_.transact(
args.at("document"), args.at("revision"),
Json::array({{{"op", "scene.simulation"}, {"value", args.at("settings")}}}));
});
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.",
+1029 -105
View File
File diff suppressed because it is too large Load Diff
+49 -4
View File
@@ -1,11 +1,13 @@
#include <algorithm>
#include <faset/editor/mcp.hpp>
#include <iostream>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <cerrno>
#include <csignal>
#include <poll.h>
#include <pthread.h>
#include <unistd.h>
#endif
@@ -126,7 +128,6 @@ std::vector<std::string> StdioTransport::poll() {
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);
@@ -169,7 +170,51 @@ std::vector<std::string> StdioTransport::poll() {
return lines;
}
void StdioTransport::send(const Json& value) {
std::cout << value.dump() << '\n';
std::cout.flush();
const auto bytes = value.dump() + '\n';
std::size_t offset = 0;
#ifdef _WIN32
const auto output = GetStdHandle(STD_OUTPUT_HANDLE);
while (offset < bytes.size()) {
DWORD written = 0;
const auto count = static_cast<DWORD>(std::min<std::size_t>(bytes.size() - offset, 65536));
if (!WriteFile(output, bytes.data() + offset, count, &written, nullptr) || written == 0) {
closed_ = true;
return;
}
offset += written;
}
#else
// A disconnected MCP client must not terminate the GUI with SIGPIPE. Block
// it only on this thread during the write, preserving the process signal
// policy and any SIGPIPE that was already pending for the caller.
sigset_t blocked, previous, pending;
sigemptyset(&blocked);
sigaddset(&blocked, SIGPIPE);
if (pthread_sigmask(SIG_BLOCK, &blocked, &previous) != 0) {
closed_ = true;
return;
}
sigpending(&pending);
const bool alreadyPending = sigismember(&pending, SIGPIPE) == 1;
bool brokenPipe = false;
while (offset < bytes.size()) {
const auto written = ::write(STDOUT_FILENO, bytes.data() + offset, bytes.size() - offset);
if (written > 0)
offset += static_cast<std::size_t>(written);
else if (written < 0 && errno == EINTR)
continue;
else {
brokenPipe = written < 0 && errno == EPIPE;
closed_ = true;
break;
}
}
if (brokenPipe && !alreadyPending) {
const timespec noWait{};
while (sigtimedwait(&blocked, nullptr, &noWait) == -1 && errno == EINTR) {
}
}
pthread_sigmask(SIG_SETMASK, &previous, nullptr);
#endif
}
} // namespace faset::editor
+23 -7
View File
@@ -14,11 +14,25 @@
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));
struct ResponseBuffer {
std::string bytes;
bool failed = false;
};
void append(void* context, const char* bytes, std::uint64_t size) noexcept {
auto& output = *static_cast<ResponseBuffer*>(context);
if (output.failed)
return;
if ((!bytes && size) || size > 8 * 1024 * 1024 ||
output.bytes.size() + size > 8 * 1024 * 1024) {
output.failed = true;
return;
}
try {
if (size)
output.bytes.append(bytes, static_cast<std::size_t>(size));
} catch (...) {
output.failed = true;
}
}
Json response(const std::string& bytes) {
auto value = Json::parse(bytes);
@@ -237,11 +251,13 @@ struct PluginManager::Impl {
commands.add(
name, descriptor.at("description"), descriptor.at("inputSchema"),
[registration](const Json& arguments) {
std::string output;
ResponseBuffer output;
const auto input = arguments.dump();
const int result =
registration.callback(registration.user, input.c_str(), append, &output);
const auto value = response(output);
require(!output.failed, "plugin.output_limit",
"Cannot collect plugin response (maximum 8 MiB)");
const auto value = response(output.bytes);
if (result != 0)
throw Error(value.value("code", std::string("plugin.failed")),
value.value("message", std::string("Plugin command failed")),
+171 -52
View File
@@ -58,6 +58,10 @@ Session::Session(SessionConfig config)
if (std::filesystem::exists(schema))
try {
load_schema(schema);
const auto state = config_.project_root / ".faset/schema-state.json";
if (std::filesystem::exists(state))
schema_source_signature_ =
read_json(state).value("source_signature", std::string());
} catch (const std::exception& error) {
log(std::string("Schema load failed: ") + error.what());
}
@@ -77,8 +81,26 @@ void Session::log(std::string value) {
}
Json Session::project() const {
const auto path = config_.project_root / "project.faset.json";
if (std::filesystem::exists(path))
return read_json(path);
if (std::filesystem::exists(path)) {
const auto value = read_json(path);
require(value.is_object() && value.value("format", "") == "faset.project" &&
value.value("version", 0) == 1,
"project.version", "Unsupported project format or version");
require(value.contains("name") && value.at("name").is_string() &&
!value.at("name").get<std::string>().empty(),
"project.name", "Project name must be a nonempty string");
const auto dimension = value.value("dimension", 3);
require(dimension == 2 || dimension == 3, "project.dimension",
"Project dimension must be 2 or 3");
if (value.contains("start_scene")) {
require(value.at("start_scene").is_string(), "project.start_scene",
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(config_.project_root, scene);
}
return value;
}
return {{"format", "faset.project"},
{"version", 1},
{"name", config_.project_root.filename().string()},
@@ -105,6 +127,26 @@ Json Session::assets_list() const {
}
return {{"assets", list}};
}
std::string Session::source_signature() const {
const auto directory = config_.project_root / "Scripts";
std::vector<std::filesystem::path> files;
if (std::filesystem::exists(directory))
for (const auto& file : std::filesystem::recursive_directory_iterator(directory))
if (file.is_regular_file())
files.push_back(file.path());
std::sort(files.begin(), files.end());
std::string contents;
for (const auto& file : files)
contents +=
file.lexically_relative(directory).generic_string() + ":" + sha256_file(file) + "\n";
return sha256(contents);
}
Json Session::schema_status() const {
return {{"loaded", schema_loaded_},
{"stale", !schema_loaded_ || schema_source_signature_ != source_signature() ||
!schema_error_.empty()},
{"error", schema_error_}};
}
Json Session::jobs() const {
Json list = Json::array();
for (const auto& item : builds_.jobs())
@@ -124,6 +166,8 @@ void Session::load_schema(const std::filesystem::path& path) {
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);
schema_loaded_ = true;
schema_error_.clear();
log("Gameplay schema loaded");
}
void Session::launch_player(Json scene, const std::filesystem::path& executable) {
@@ -170,20 +214,35 @@ void Session::poll() {
if (observed_jobs_[value.id] == value.state)
continue;
observed_jobs_[value.id] = value.state;
if (value.state == "failed")
log(value.kind + " failed: " + value.error);
bool schema_valid = true;
if (value.state == "failed") {
schema_error_ = value.error;
log(value.kind + " failed: " + value.error +
"; previous gameplay metadata remains available and is marked stale");
}
if (value.state == "succeeded") {
log(value.kind + " completed");
if (value.result.contains("schema"))
try {
load_schema(value.result.at("schema").get<std::string>());
// This signature represents the sources submitted with this job, not later
// edits.
if (value.result.contains("source_signature"))
schema_source_signature_ =
value.result.at("source_signature").get<std::string>();
else if (submitted_sources_.contains(value.id))
schema_source_signature_ = submitted_sources_.at(value.id);
atomic_write_json(config_.project_root / ".faset/schema-state.json",
{{"source_signature", schema_source_signature_}});
} catch (const std::exception& error) {
schema_valid = false;
schema_error_ = error.what();
log(std::string("Schema update failed: ") + error.what());
}
}
if (value.id == pending_play_job_ && value.finished()) {
pending_play_job_.clear();
if (value.state == "succeeded")
if (value.state == "succeeded" && schema_valid)
try {
const auto executable =
value.result.value("player", (builds_.config().build_directory /
@@ -242,67 +301,125 @@ void Session::register_commands() {
commands_.add(
"faset_project", "Read the authoring project's settings.", schema(Json::object()),
[&](const Json&) { return project(); }, true);
commands_.add(
"faset_project_settings_get", "Read project settings and their content revision.",
schema(Json::object()),
[&](const Json&) {
const auto value = project();
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
},
true);
commands_.add(
"faset_project_settings_set",
"Save project name, initial scene dimension or start scene with an expected content "
"revision. Applies on the next project open; does not change the active scene or its Undo "
"history.",
schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}),
[&](const Json& args) {
auto value = project();
require(args.at("revision") == sha256(value.dump()), "revision.conflict",
"Project settings changed; reload them before saving");
const auto& changes = args.at("settings");
for (const auto& [key, field] : changes.items()) {
require(key == "name" || key == "dimension" || key == "start_scene",
"project.setting", "Unknown editable project setting: " + key);
if (key == "name")
require(field.is_string() && !field.get<std::string>().empty(), "project.name",
"Project name must be a nonempty string");
else if (key == "dimension")
require(field.is_number_integer() && (field == 2 || field == 3),
"project.dimension", "Initial scene dimension must be 2 or 3");
else {
require(field.is_string() && !field.get<std::string>().empty(),
"project.start_scene", "Choose a saved scene inside the project");
const auto file = project_path(config_.project_root, field.get<std::string>());
require(std::filesystem::is_regular_file(file), "project.start_scene",
"Save the start scene before selecting it in Project settings");
const auto scene = read_json(file);
require(scene.value("format", "") == "faset.scene" &&
scene.value("version", 0) == 1,
"project.start_scene",
"The start scene must be a supported Faset scene");
}
value[key] = field;
}
if (!value.contains("id"))
value["id"] = new_id();
atomic_write_json(config_.project_root / "project.faset.json", value);
log("Project settings saved; changes apply on next project open");
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
});
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"
commands_.add(
"faset_import",
"Import PNG/JPEG, 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}};
});
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_schema_status",
"Report whether the last successful gameplay schema matches current project scripts. "
"Failed builds retain metadata but mark it stale.",
schema(Json::object()), [&](const Json&) { return schema_status(); }, true);
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()}}; });
schema(Json::object()), [&](const Json&) {
const auto signature = source_signature();
const auto id = builds_.start_build();
submitted_sources_[id] = signature;
return Json{{"job", id}};
});
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>()))}};
const auto signature = source_signature();
const auto id = builds_.start_export(
resolved_or_throw(commands_, args.at("document")),
project_path(config_.project_root, args.at("output").get<std::string>()));
submitted_sources_[id] = signature;
return Json{{"job", id}};
});
commands_.add(
"faset_jobs", "List editor import/build/export jobs and their progress.",
@@ -326,7 +443,9 @@ void Session::register_commands() {
schema({{"document", text}}, {"document"}), [&](const Json& args) {
stop_player();
pending_play_scene_ = resolved_or_throw(commands_, args.at("document"));
const auto signature = source_signature();
pending_play_job_ = builds_.start_build();
submitted_sources_[pending_play_job_] = signature;
return Json{{"job", pending_play_job_}, {"play_pending", true}};
});
commands_.add(