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

This commit is contained in:
Emil
2026-09-18 03:40:15 +03:00
parent 5c6b24d34d
commit 999686a896
125 changed files with 16086 additions and 1714 deletions
+9
View File
@@ -0,0 +1,9 @@
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
AllowShortFunctionsOnASingleLine: Empty
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
BreakBeforeBraces: Attach
PointerAlignment: Left
SortIncludes: CaseSensitive
+2
View File
@@ -3,3 +3,5 @@
licenses/*.txt -whitespace licenses/*.txt -whitespace
*.png binary *.png binary
*.ttf binary *.ttf binary
assets/fonts/OFL.txt -whitespace
+35 -2
View File
@@ -27,7 +27,7 @@ else()
endif() endif()
include(cmake/Dependencies.cmake) include(cmake/Dependencies.cmake)
add_library(faset_core STATIC src/core/hash.cpp src/core/io.cpp) add_library(faset_core STATIC src/core/hash.cpp src/core/io.cpp src/core/process.cpp)
target_include_directories(faset_core PUBLIC include) target_include_directories(faset_core PUBLIC include)
target_link_libraries(faset_core PUBLIC nlohmann_json::nlohmann_json Threads::Threads) target_link_libraries(faset_core PUBLIC nlohmann_json::nlohmann_json Threads::Threads)
target_compile_definitions(faset_core PUBLIC FASET_VERSION="${PROJECT_VERSION}") target_compile_definitions(faset_core PUBLIC FASET_VERSION="${PROJECT_VERSION}")
@@ -50,7 +50,21 @@ endforeach()
if(FASET_BUILD_RENDERER AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Renderer.cmake") if(FASET_BUILD_RENDERER AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Renderer.cmake")
include(cmake/Renderer.cmake) include(cmake/Renderer.cmake)
endif() endif()
foreach(module UI Editor Applications) if(TARGET faset_gameplay AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Player.cmake")
include(cmake/Player.cmake)
endif()
if(TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/EditorCommands.cmake")
include(cmake/EditorCommands.cmake)
endif()
if(TARGET faset_assets AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/BuildService.cmake")
include(cmake/BuildService.cmake)
endif()
if(TARGET faset_editor_commands AND TARGET faset_build_service)
include(cmake/Plugins.cmake)
add_library(faset_editor_session STATIC src/editor/session.cpp)
target_link_libraries(faset_editor_session PUBLIC faset_editor_commands faset_build_service faset_assets faset_editor_plugins)
endif()
foreach(module UI EditorUI Editor Applications)
if(NOT FASET_BUILD_EDITOR) if(NOT FASET_BUILD_EDITOR)
continue() continue()
endif() endif()
@@ -59,8 +73,27 @@ foreach(module UI Editor Applications)
endif() endif()
endforeach() endforeach()
if(TARGET faset_editor_session)
add_executable(faset_editor apps/editor_main.cpp)
target_link_libraries(faset_editor PRIVATE faset_editor_session)
target_compile_definitions(faset_editor PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
if(BUILD_TESTING)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
add_test(NAME editor_mcp_stdio COMMAND ${Python3_EXECUTABLE} ${PROJECT_SOURCE_DIR}/tests/mcp_stdio_test.py $<TARGET_FILE:faset_editor>)
set_tests_properties(editor_mcp_stdio PROPERTIES TIMEOUT 60)
endif()
if(TARGET faset_editor_ui)
target_link_libraries(faset_editor PRIVATE faset_editor_ui)
target_link_libraries(faset_editor PRIVATE faset_stb)
target_compile_definitions(faset_editor PRIVATE FASET_HAS_EDITOR_UI=1)
target_sources(faset_editor PRIVATE apps/editor_gui.cpp)
endif()
endif()
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(faset_core_tests tests/core_tests.cpp) add_executable(faset_core_tests tests/core_tests.cpp)
target_link_libraries(faset_core_tests PRIVATE faset_core) target_link_libraries(faset_core_tests PRIVATE faset_core)
add_test(NAME core COMMAND faset_core_tests) add_test(NAME core COMMAND faset_core_tests)
endif() endif()
include(cmake/Tutorials.cmake)
+1 -1
View File
@@ -2,7 +2,7 @@
Faset is an independent engine project for desktop **2D and 3D games on Linux and Windows**. Its priorities are a custom editor that is comfortable to use by hand and through MCP, integration with Blender, and a path toward advanced graphics. Faset is an independent engine project for desktop **2D and 3D games on Linux and Windows**. Its priorities are a custom editor that is comfortable to use by hand and through MCP, integration with Blender, and a path toward advanced graphics.
**Current status: MVP implementation is in progress.** Native core, authoring, physics runtime, asset import, and renderer subsystems are being integrated and tested. The complete editor and game export workflow are not yet ready. See the [implementation checkpoints](docs/IMPLEMENTATION.md) for observed results; a technology appearing in the plan does not mean it is complete or benchmarked. **Current status: MVP implementation is in progress.** The native Editor, shared GUI/MCP authoring, C++ gameplay builds, Vulkan Player, and standalone export are integrated and under acceptance testing. Linux GPU workflows work; complete cross-platform acceptance and the two finished example games remain in progress. See the [implementation checkpoints](docs/IMPLEMENTATION.md) for observed results; a technology appearing in the plan does not mean it is complete or benchmarked.
## Start here ## Start here
+110
View File
@@ -0,0 +1,110 @@
#include <algorithm>
#include <chrono>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <faset/editor/mcp.hpp>
#include <thread>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <stb_image_write.h>
namespace faset::editor {
namespace {
std::string base64(const std::vector<unsigned char>& bytes) {
constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((bytes.size() + 2) / 3) * 4);
for (std::size_t i = 0; i < bytes.size(); i += 3) {
const auto a = bytes[i];
const auto b = i + 1 < bytes.size() ? bytes[i + 1] : 0,
c = i + 2 < bytes.size() ? bytes[i + 2] : 0;
out += alphabet[a >> 2];
out += alphabet[((a & 3) << 4) | (b >> 4)];
out += i + 1 < bytes.size() ? alphabet[((b & 15) << 2) | (c >> 6)] : '=';
out += i + 2 < bytes.size() ? alphabet[c & 63] : '=';
}
return out;
}
std::vector<unsigned char> png(render::Renderer& renderer, std::array<float, 4> rectangle) {
const auto width = renderer.width(), height = renderer.height();
const auto pixels = renderer.pixels();
int x = std::clamp(static_cast<int>(rectangle[0]), 0, static_cast<int>(width) - 1),
y = std::clamp(static_cast<int>(rectangle[1]), 0, static_cast<int>(height) - 1);
const int w = std::clamp(static_cast<int>(rectangle[2]), 1, static_cast<int>(width) - x),
h = std::clamp(static_cast<int>(rectangle[3]), 1, static_cast<int>(height) - y);
std::vector<unsigned char> cropped(static_cast<std::size_t>(w) * h * 4), encoded;
for (int row = 0; row < h; ++row)
std::copy_n(pixels.begin() + (static_cast<std::size_t>(row + y) * width + x) * 4,
static_cast<std::size_t>(w) * 4,
cropped.begin() + static_cast<std::size_t>(row) * w * 4);
const auto writer = [](void* context, void* data, int count) {
auto& out = *static_cast<std::vector<unsigned char>*>(context);
auto* begin = static_cast<unsigned char*>(data);
out.insert(out.end(), begin, begin + count);
};
require(stbi_write_png_to_func(writer, &encoded, w, h, 4, cropped.data(), w * 4) != 0,
"capture.encode", "Cannot encode editor screenshot");
return encoded;
}
} // namespace
int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames,
const std::filesystem::path& capture) {
render::Renderer renderer({1440, 900,
"Faset — " + session.project().value("name", std::string("Project")),
false, true});
const auto font = session.config().engine_root / "assets/fonts/NotoSans.ttf";
const auto theme = session.config().engine_root / "assets/ui/dark.json";
EditorUI ui(session, renderer, font, theme);
McpServer server(session.commands());
StdioTransport transport;
session.commands().add(
"faset_editor_capture",
"Capture the Editor or its authoring viewport as a PNG image. Requires the graphical "
"Editor and Vulkan; never captures a Player process.",
Commands::object_schema(
{{"path", {{"type", "string"}}}, {"viewport_only", {{"type", "boolean"}}}}),
[&](const Json& arguments) {
session.poll();
ui.frame({});
renderer.render(ui.snapshot());
auto region =
arguments.value("viewport_only", true)
? ui.snapshot().scene_rect
: std::array<float, 4>{0, 0, float(renderer.width()), float(renderer.height())};
if (region[2] <= 0 || region[3] <= 0)
region = {0, 0, float(renderer.width()), float(renderer.height())};
auto encoded = png(renderer, region);
const auto relative =
arguments.value("path", std::string(".faset/screenshots/editor.png"));
atomic_write(
project_path(session.config().project_root, relative),
std::string_view(reinterpret_cast<const char*>(encoded.data()), encoded.size()));
return Json{{"path", relative},
{"mimeType", "image/png"},
{"width", static_cast<int>(region[2])},
{"height", static_cast<int>(region[3])},
{"image_base64", base64(encoded)}};
},
true);
std::uint64_t frame = 0;
while (!renderer.should_close() && (max_frames == 0 || frame < max_frames)) {
if (enable_mcp)
for (const auto& line : transport.poll()) {
try {
const auto reply = server.handle(Json::parse(line));
if (reply)
transport.send(*reply);
} catch (const Json::exception&) {
transport.send(server.parse_error());
}
}
session.poll();
ui.frame(renderer.poll_events());
renderer.render(ui.snapshot());
++frame;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
if (!capture.empty())
renderer.capture(capture);
return renderer.stats().validation_errors == 0 ? 0 : 2;
}
} // namespace faset::editor
+172
View File
@@ -0,0 +1,172 @@
#include <chrono>
#include <csignal>
#include <faset/core/io.hpp>
#include <faset/editor/mcp.hpp>
#include <faset/editor/session.hpp>
#include <iostream>
#include <thread>
#ifdef FASET_HAS_EDITOR_UI
#include <faset/editor/editor_ui.hpp>
namespace faset::editor {
int run_editor_ui(Session&, bool, std::uint64_t, const std::filesystem::path&);
}
#endif
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#endif
namespace {
volatile std::sig_atomic_t interrupted = 0;
void interrupt(int) {
interrupted = 1;
}
std::filesystem::path executable_directory(const char* argument) {
#ifdef _WIN32
std::wstring path(32768, L'\0');
const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
if (length == 0 || length >= path.size())
throw std::runtime_error("Cannot locate Editor executable");
path.resize(length);
return std::filesystem::path(path).parent_path();
#else
std::error_code error;
const auto path = std::filesystem::read_symlink("/proc/self/exe", error);
return error ? std::filesystem::absolute(argument).parent_path() : path.parent_path();
#endif
}
void help() {
std::cout
<< "Faset Editor\n"
" faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n"
" faset_editor --project PATH --mcp [--gui]\n"
" faset_editor --project PATH --command JSON [--wait]\n"
"Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n"
"MCP uses JSON-RPC over stdio and only exposes authoring/editor services.\n";
}
} // namespace
int main(int argc, char** argv) {
using namespace faset;
using namespace faset::editor;
try {
std::filesystem::path project, engine = FASET_ENGINE_SOURCE, scene, capture;
std::string new_name, command;
int dimension = 3;
bool mcp = false, gui = true, explicit_gui = false, wait = false;
std::uint64_t frames = 0;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
auto value = [&]() {
require(i + 1 < argc, "cli.argument", "Missing value for " + arg);
return std::string(argv[++i]);
};
if (arg == "--help" || arg == "-h") {
help();
return 0;
}
if (arg == "--project")
project = value();
else if (arg == "--engine")
engine = value();
else if (arg == "--new")
new_name = value();
else if (arg == "--dimension")
dimension = std::stoi(value());
else if (arg == "--scene")
scene = value();
else if (arg == "--mcp")
mcp = true;
else if (arg == "--gui") {
gui = true;
explicit_gui = true;
} else if (arg == "--headless")
gui = false;
else if (arg == "--command") {
command = value();
gui = false;
} else if (arg == "--wait")
wait = true;
else if (arg == "--frames") {
const auto text = value();
require(!text.empty() && text.find_first_not_of("0123456789") == std::string::npos,
"cli.frames", "Frame count must be positive");
frames = std::stoull(text);
require(frames > 0 && frames <= 10000000, "cli.frames", "Frame count out of range");
} else if (arg == "--capture")
capture = value();
else
throw Error("cli.option", "Unknown option: " + arg);
}
require(!project.empty(), "cli.project", "Use --project PATH to select a project");
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command");
if (mcp && !explicit_gui)
gui = false;
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
executable_directory(argv[0])});
if (!new_name.empty())
session.scaffold(new_name, dimension);
const auto settings = session.project();
if (scene.empty())
scene = settings.value("start_scene", std::string());
if (!scene.empty() &&
std::filesystem::exists(project_path(session.config().project_root, scene)))
session.authoring().open(scene);
if (!command.empty()) {
const auto request = Json::parse(command);
auto result = session.commands().call(request.at("name"),
request.value("arguments", Json::object()));
if (wait && result.contains("job")) {
const auto id = result.at("job");
const auto started = std::chrono::steady_clock::now();
for (;;) {
session.poll();
result = session.commands().call("faset_job", {{"id", id}});
const auto state = result.value("state", std::string());
if (state == "succeeded" || state == "failed" || state == "cancelled" ||
state == "conflict")
break;
require(std::chrono::steady_clock::now() - started < std::chrono::minutes(30),
"job.timeout", "Command wait exceeded 30 minutes");
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
}
std::cout << result.dump(2) << '\n';
const auto state = result.value("state", std::string());
return state == "failed" || state == "cancelled" || state == "conflict" ? 1 : 0;
}
if (gui) {
#ifdef FASET_HAS_EDITOR_UI
return run_editor_ui(session, mcp, frames, capture);
#else
throw Error("editor.gui_unavailable",
"This build has no graphical editor; use --mcp or --command, or build with "
"FASET_BUILD_EDITOR=ON");
#endif
}
require(mcp, "cli.mode", "Headless mode requires --mcp or --command");
std::signal(SIGINT, interrupt);
std::signal(SIGTERM, interrupt);
McpServer server(session.commands());
StdioTransport transport;
while (!interrupted && !transport.closed()) {
for (const auto& line : transport.poll()) {
try {
const auto reply = server.handle(Json::parse(line));
if (reply)
transport.send(*reply);
} catch (const Json::exception&) {
transport.send(server.parse_error());
}
}
session.poll();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
return 0;
} catch (const Error& error) {
std::cerr << error.json().dump() << '\n';
return 1;
} catch (const std::exception& error) {
std::cerr << Json{{"code", "editor.failure"}, {"message", error.what()}}.dump() << '\n';
return 1;
}
}
+296
View File
@@ -0,0 +1,296 @@
#include "Gameplay.hpp"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp>
#include <filesystem>
#include <iostream>
#include <set>
#include <stdexcept>
#if defined(_WIN32)
#define NOMINMAX
#include <windows.h>
#endif
namespace {
std::filesystem::path executableDirectory(const char* argument) {
#if defined(_WIN32)
std::wstring path(32768, L'\0');
const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
if (length == 0 || length >= path.size())
throw std::runtime_error("Cannot locate Player executable");
path.resize(length);
return std::filesystem::path(path).parent_path();
#else
std::error_code error;
auto executable = std::filesystem::read_symlink("/proc/self/exe", error);
return error ? std::filesystem::absolute(argument).parent_path() : executable.parent_path();
#endif
}
std::uint64_t count(const std::string& value) {
if (value.empty() || value.find_first_not_of("0123456789") != std::string::npos)
throw std::invalid_argument("Frame count must be a positive integer");
auto result = std::stoull(value);
if (result == 0 || result > 10000000)
throw std::invalid_argument("Frame count out of range");
return result;
}
faset::runtime::RuntimeConfig simulationConfig(const nlohmann::json& scene) {
faset::runtime::RuntimeConfig config;
if (!scene.contains("simulation"))
return config;
const auto& settings = scene.at("simulation");
if (!settings.is_object())
throw std::invalid_argument("simulation must be an object");
config.fixedDelta = settings.value("fixed_delta", config.fixedDelta);
auto boundedInteger = [&](const char* key, int fallback, int maximum) {
if (!settings.contains(key))
return fallback;
const auto& value = settings.at(key);
if (!value.is_number_integer())
throw std::invalid_argument(std::string(key) + " must be an integer");
// Check before narrowing, so very large unsigned values cannot wrap into
// a valid configuration on a platform with a narrower unsigned type.
const auto numeric = value.get<double>();
if (numeric < 1 || numeric > maximum)
throw std::invalid_argument(std::string(key) + " is out of range");
return value.get<int>();
};
config.maxCatchUpTicks = static_cast<unsigned>(
boundedInteger("max_catch_up_ticks", static_cast<int>(config.maxCatchUpTicks), 1024));
config.physicsSubsteps = boundedInteger("physics_substeps", config.physicsSubsteps, 128);
if (settings.contains("gravity")) {
const auto& gravity = settings.at("gravity");
if (!gravity.is_array() || gravity.size() != 3)
throw std::invalid_argument("gravity must contain three numbers");
config.gravity = gravity.get<faset::runtime::Vec3>();
}
return config;
}
void validatePackagedShaders(const std::filesystem::path& directory) {
for (const auto* name : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) {
const auto path = directory / "shaders" / name;
if (!std::filesystem::is_regular_file(path))
throw std::runtime_error("Packaged shader is missing: " + path.string());
const auto bytes = faset::read_text(path);
if (bytes.size() < 20 || bytes.size() % 4 != 0 ||
static_cast<unsigned char>(bytes[0]) != 0x03 ||
static_cast<unsigned char>(bytes[1]) != 0x02 ||
static_cast<unsigned char>(bytes[2]) != 0x23 ||
static_cast<unsigned char>(bytes[3]) != 0x07)
throw std::runtime_error("Packaged shader is not a SPIR-V module: " + path.string());
}
}
} // namespace
int main(int argc, char** argv) {
try {
std::filesystem::path scenePath, assetsPath, capturePath, controlPath;
bool headless = false, validateOnly = false;
std::uint64_t maximumFrames = 0;
std::set<std::string> options;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "--help") {
std::cout << "faset_player [--scene PATH] [--assets CACHE] [--frames N] "
"[--headless] [--capture PATH.ppm] [--validate] [--control PATH]\n"
"No --scene: open scene.fscene beside the executable. CACHE contains "
"assets/<id>/.\n"
"Headless uses offscreen Vulkan; --frames uses the configured fixed "
"simulation delta.\n"
"--validate checks scene/resources on CPU without gameplay callbacks "
"or Vulkan initialization.\n"
"--control is an optional editor mailbox for pause/resume/step/stop, "
"without world queries.\n"
"Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, "
"N single-step, Escape quit.\n";
return 0;
}
if (!options.insert(arg).second)
throw std::invalid_argument("Repeated option: " + arg);
auto value = [&]() -> std::string {
if (i + 1 >= argc)
throw std::invalid_argument("Missing value for " + arg);
return argv[++i];
};
if (arg == "--scene")
scenePath = value();
else if (arg == "--assets")
assetsPath = value();
else if (arg == "--capture")
capturePath = value();
else if (arg == "--control")
controlPath = value();
else if (arg == "--frames")
maximumFrames = count(value());
else if (arg == "--headless")
headless = true;
else if (arg == "--validate")
validateOnly = true;
else
throw std::invalid_argument("Unknown option: " + arg);
}
if (scenePath.empty())
scenePath = executableDirectory(argv[0]) / "scene.fscene";
scenePath = std::filesystem::absolute(scenePath).lexically_normal();
if (assetsPath.empty())
assetsPath = scenePath.parent_path();
if (!std::filesystem::is_directory(assetsPath))
throw std::invalid_argument("Asset cache directory does not exist: " +
assetsPath.string());
if (headless && maximumFrames == 0)
maximumFrames = 1;
const auto document = faset::player::readScene(scenePath);
const auto config = simulationConfig(document);
const auto executableRoot = executableDirectory(argv[0]);
if (scenePath.extension() == ".fscene" &&
std::filesystem::equivalent(scenePath.parent_path(), executableRoot))
validatePackagedShaders(executableRoot);
if (validateOnly) {
if (!capturePath.empty() || !controlPath.empty())
throw std::invalid_argument("--validate cannot capture or control a running game");
faset::runtime::Runtime validator(config);
validator.load(document);
faset::player::SceneView view(assetsPath);
view.build(document, 16.0f / 9.0f);
for (const auto& diagnostic : view.diagnostics()) {
if (diagnostic.starts_with("error:"))
throw std::runtime_error(diagnostic);
std::cerr << diagnostic << '\n';
}
std::cout << nlohmann::json{{"validated", true},
{"dimension", document.value("dimension", 3)}}
.dump()
<< '\n';
return 0;
}
faset::runtime::Runtime world(config);
faset::gameplay::registerGameplay(world);
world.load(document);
faset::player::SceneView view(assetsPath);
faset::render::Renderer renderer(
{1280, 720, document.value("name", std::string("Faset Player")), headless, true});
std::set<std::string> held;
bool stop = false;
std::uint64_t frames = 0;
std::size_t logCursor = 0;
std::set<std::string> reported;
std::uint64_t controlSequence = 0;
std::string previousControl;
auto previous = std::chrono::steady_clock::now();
while (!stop && !renderer.should_close() &&
(maximumFrames == 0 || frames < maximumFrames)) {
faset::runtime::InputState input;
bool singleStep = false;
if (!controlPath.empty() && std::filesystem::is_regular_file(controlPath)) {
try {
if (std::filesystem::file_size(controlPath) > 65536)
throw std::runtime_error("control message exceeds 64 KiB");
auto content = faset::read_text(controlPath);
if (content != previousControl) {
previousControl = content;
const auto message = nlohmann::json::parse(content);
const auto& sequence = message.at("sequence");
if (!(sequence.is_number_unsigned() ||
(sequence.is_number_integer() && sequence.get<std::int64_t>() >= 0)))
throw std::invalid_argument(
"control sequence must be a nonnegative integer");
const auto value = sequence.get<std::uint64_t>();
if (value > controlSequence) {
const auto command = message.at("command").get<std::string>();
if (command == "pause")
world.setPaused(true);
else if (command == "resume")
world.setPaused(false);
else if (command == "step") {
world.setPaused(true);
singleStep = true;
} else if (command == "stop")
stop = true;
else
throw std::invalid_argument("unsupported control command");
controlSequence = value;
}
}
} catch (const std::exception& error) {
const std::string message =
std::string("Player control ignored: ") + error.what();
if (reported.insert(message).second)
std::cerr << message << '\n';
}
}
for (const auto& event : renderer.poll_events()) {
using Type = faset::render::Event::Type;
if (event.type == Type::Quit)
stop = true;
if (event.type == Type::FocusLost)
held.clear();
std::string key = event.key;
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
if (event.type == Type::KeyUp)
held.erase(key);
if (event.type == Type::KeyDown) {
held.insert(key);
if (key == "ESCAPE")
stop = true;
if (!event.repeat) {
if (key == "SPACE")
input.jumpPressed = true;
if (key == "E")
input.interactPressed = true;
if (key == "P")
world.setPaused(!world.paused());
if (key == "N")
singleStep = true;
}
}
}
if (stop)
break;
input.horizontal = float(held.contains("D") || held.contains("RIGHT")) -
float(held.contains("A") || held.contains("LEFT"));
input.vertical = float(held.contains("W") || held.contains("UP")) -
float(held.contains("S") || held.contains("DOWN"));
const auto now = std::chrono::steady_clock::now();
const double elapsed = maximumFrames
? config.fixedDelta
: std::chrono::duration<double>(now - previous).count();
previous = now;
if (singleStep && world.paused())
world.singleStep(input);
else
world.advance(elapsed, input);
auto snapshot = view.build(world.snapshotJson(), static_cast<float>(renderer.width()) /
std::max(1u, renderer.height()));
for (const auto& diagnostic : view.diagnostics()) {
if (diagnostic.starts_with("error:"))
throw std::runtime_error(diagnostic);
if (reported.insert(diagnostic).second)
std::cerr << diagnostic << '\n';
}
while (logCursor < world.diagnostics().size())
std::cerr << world.diagnostics()[logCursor++] << '\n';
renderer.render(snapshot);
++frames;
}
if (!capturePath.empty()) {
if (frames == 0)
throw std::runtime_error("No frame was rendered for capture");
renderer.capture(capturePath);
}
const auto stats = renderer.stats();
std::cout << nlohmann::json{{"frames", frames},
{"ticks", world.snapshot().tick},
{"dimension", document.value("dimension", 3)},
{"device", stats.device},
{"validation_errors", stats.validation_errors}}
.dump()
<< '\n';
return stats.validation_errors == 0 ? 0 : 2;
} catch (const std::exception& error) {
std::cerr << "Player failed: " << error.what() << '\n';
return 1;
}
}
+35
View File
@@ -0,0 +1,35 @@
#include "Gameplay.hpp"
#include <faset/core/io.hpp>
#include <iostream>
#include <stdexcept>
int main(int argc, char** argv) {
try {
std::filesystem::path output;
for (int i = 1; i < argc; ++i) {
const std::string argument = argv[i];
if (argument == "--help") {
std::cout << "faset_schema_exporter [--output PATH]\nExports declarative gameplay "
"schemas without creating a world.\n";
return 0;
}
if (argument == "--output" && i + 1 < argc && output.empty())
output = argv[++i];
else
throw std::invalid_argument("Unknown, repeated or incomplete argument: " +
argument);
}
const auto types = faset::gameplay::schema();
if (!types.is_array())
throw std::runtime_error("Gameplay schema() must return a type array");
const nlohmann::json manifest{{"format", "faset.schema"}, {"version", 1}, {"types", types}};
if (output.empty())
std::cout << manifest.dump(2) << '\n';
else
faset::atomic_write_json(output, manifest);
return 0;
} catch (const std::exception& error) {
std::cerr << "Schema export failed: " << error.what() << '\n';
return 1;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Noto Sans
The Faset Editor bundles Noto Sans under the SIL Open Font License 1.1.
The unmodified font and complete license are `NotoSans.ttf` and `OFL.txt`.
Source: [google/fonts, Noto Sans](https://github.com/google/fonts/tree/a54f7446f84a1125ef6bf08baa46f3639e8905e0/ofl/notosans).
The source path, exact commit and SHA-256 are recorded in `source.json`.
This font is an Editor asset; exported Players do not require it.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Noto Sans",
"repository": "https://github.com/google/fonts",
"commit": "a54f7446f84a1125ef6bf08baa46f3639e8905e0",
"path": "ofl/notosans/NotoSans[wdth,wght].ttf",
"sha256": "bfb7bb691513f12e734dc346c03a03f784912432d7e3fa8e56efcf906fe86b3d",
"license": "SIL Open Font License 1.1",
"license_file": "OFL.txt",
"license_sha256": "cee9892f9f0cc8fe882c9e9537ee6a89621d86ee7ceaf70b02e2b2b1c25c061a"
}
+16
View File
@@ -0,0 +1,16 @@
{
"background": [0.073, 0.078, 0.090, 1],
"surface": [0.102, 0.108, 0.122, 1],
"raised": [0.145, 0.151, 0.169, 1],
"hover": [0.19, 0.197, 0.219, 1],
"border": [0.24, 0.25, 0.278, 1],
"text": [0.88, 0.889, 0.91, 1],
"muted": [0.59, 0.61, 0.65, 1],
"accent": [0.65, 0.60, 0.88, 1],
"selection": [0.26, 0.245, 0.35, 1],
"danger": [0.92, 0.39, 0.38, 1],
"font_size": 14,
"row_height": 28,
"padding": 8,
"gap": 4
}
+19
View File
@@ -0,0 +1,19 @@
{
"root": {
"id": "root", "kind": "column", "layout": {"gap": 1},
"children": [
{"id": "menubar", "kind": "panel", "layout": {"height": 34, "padding": 3}},
{"id": "toolbar", "kind": "panel", "layout": {"height": 40, "padding": 5}},
{"id": "workspace", "kind": "row", "layout": {"flex": 1, "gap": 1}, "children": [
{"id": "scene_panel", "kind": "panel", "layout": {"width": 224, "min_width": 150, "gap": 0}},
{"id": "left_divider", "kind": "divider", "layout": {"width": 5}},
{"id": "viewport", "kind": "viewport", "layout": {"flex": 1, "min_width": 180}},
{"id": "right_divider", "kind": "divider", "layout": {"width": 5}},
{"id": "inspector_panel", "kind": "panel", "layout": {"width": 300, "min_width": 210, "gap": 0}}
]},
{"id": "bottom_divider", "kind": "divider", "layout": {"height": 5}},
{"id": "bottom_panel", "kind": "panel", "layout": {"height": 184, "min_height": 90, "gap": 0}},
{"id": "statusbar", "kind": "panel", "layout": {"height": 26, "padding": 0}}
]
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
add_library(faset_asset_data STATIC ${PROJECT_SOURCE_DIR}/src/assets/asset_data.cpp)
target_compile_features(faset_asset_data PUBLIC cxx_std_20)
target_include_directories(faset_asset_data PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_link_libraries(faset_asset_data PUBLIC faset_core nlohmann_json::nlohmann_json)
add_library(faset_assets add_library(faset_assets
${PROJECT_SOURCE_DIR}/src/assets/asset_pipeline.cpp ${PROJECT_SOURCE_DIR}/src/assets/asset_pipeline.cpp
${PROJECT_SOURCE_DIR}/src/assets/cgltf.cpp) ${PROJECT_SOURCE_DIR}/src/assets/cgltf.cpp)
target_compile_features(faset_assets PUBLIC cxx_std_20) target_compile_features(faset_assets PUBLIC cxx_std_20)
target_include_directories(faset_assets PUBLIC ${PROJECT_SOURCE_DIR}/include) target_include_directories(faset_assets PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_link_libraries(faset_assets PUBLIC faset_core nlohmann_json::nlohmann_json PRIVATE faset_cgltf) target_link_libraries(faset_assets PUBLIC faset_asset_data PRIVATE faset_cgltf faset_stb)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(faset_assets_tests ${PROJECT_SOURCE_DIR}/tests/assets_pipeline.cpp) add_executable(faset_assets_tests ${PROJECT_SOURCE_DIR}/tests/assets_pipeline.cpp)
target_link_libraries(faset_assets_tests PRIVATE faset_assets) target_link_libraries(faset_assets_tests PRIVATE faset_assets)
+1 -1
View File
@@ -1,4 +1,4 @@
add_library(faset_authoring STATIC src/authoring/schema.cpp src/authoring/service.cpp src/authoring/templates.cpp) add_library(faset_authoring STATIC src/authoring/schema.cpp src/authoring/service.cpp src/authoring/templates.cpp src/authoring/transforms.cpp)
target_include_directories(faset_authoring PUBLIC "${PROJECT_SOURCE_DIR}/include") target_include_directories(faset_authoring PUBLIC "${PROJECT_SOURCE_DIR}/include")
target_link_libraries(faset_authoring PUBLIC faset_core) target_link_libraries(faset_authoring PUBLIC faset_core)
if(BUILD_TESTING) if(BUILD_TESTING)
+10
View File
@@ -0,0 +1,10 @@
add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp)
target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_compile_features(faset_build_service PUBLIC cxx_std_20)
target_link_libraries(faset_build_service PUBLIC faset_core PRIVATE faset_asset_data Threads::Threads)
if(BUILD_TESTING)
add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp)
target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets)
target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
add_test(NAME process_and_cook COMMAND faset_build_service_tests)
endif()
+8
View File
@@ -0,0 +1,8 @@
add_library(faset_editor_commands STATIC src/editor/commands.cpp src/editor/mcp.cpp)
target_link_libraries(faset_editor_commands PUBLIC faset_authoring)
target_include_directories(faset_editor_commands PUBLIC include)
if(BUILD_TESTING)
add_executable(faset_mcp_tests tests/mcp_tests.cpp)
target_link_libraries(faset_mcp_tests PRIVATE faset_editor_commands)
add_test(NAME editor_mcp COMMAND faset_mcp_tests)
endif()
+15
View File
@@ -0,0 +1,15 @@
if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view)
add_library(faset_editor_ui STATIC ${PROJECT_SOURCE_DIR}/src/editor/editor_ui.cpp)
target_link_libraries(faset_editor_ui PUBLIC faset_ui faset_editor_session faset_scene_view)
if(BUILD_TESTING)
add_executable(faset_editor_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_tests.cpp)
target_link_libraries(faset_editor_ui_tests PRIVATE faset_editor_ui)
target_compile_definitions(faset_editor_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
if(TARGET faset_example_plugin)
add_dependencies(faset_editor_ui_tests faset_example_plugin)
target_compile_definitions(faset_editor_ui_tests PRIVATE FASET_TEST_PLUGIN_DIRECTORY="${PROJECT_BINARY_DIR}/example-plugin")
endif()
add_test(NAME editor_ui_authoring COMMAND faset_editor_ui_tests)
set_tests_properties(editor_ui_authoring PROPERTIES LABELS "gpu")
endif()
endif()
+24
View File
@@ -0,0 +1,24 @@
if(TARGET faset_gameplay)
add_executable(faset_schema_exporter ${PROJECT_SOURCE_DIR}/apps/schema_exporter_main.cpp)
target_link_libraries(faset_schema_exporter PRIVATE faset_core faset_gameplay)
endif()
if(TARGET faset_runtime AND TARGET faset_render AND TARGET faset_assets)
add_library(faset_scene_view STATIC
${PROJECT_SOURCE_DIR}/src/player/SceneView.cpp
${PROJECT_SOURCE_DIR}/src/player/scene_io.cpp)
target_include_directories(faset_scene_view PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_link_libraries(faset_scene_view PUBLIC faset_render faset_core PRIVATE faset_asset_data)
if(TARGET faset_stb)
target_link_libraries(faset_scene_view PRIVATE faset_stb)
target_compile_definitions(faset_scene_view PRIVATE FASET_HAS_STB=1)
endif()
add_executable(faset_player ${PROJECT_SOURCE_DIR}/apps/player_main.cpp)
target_link_libraries(faset_player PRIVATE faset_scene_view faset_runtime faset_gameplay)
install(TARGETS faset_player RUNTIME DESTINATION .)
if(BUILD_TESTING)
add_executable(faset_player_tests ${PROJECT_SOURCE_DIR}/tests/runtime_player_tests.cpp)
target_link_libraries(faset_player_tests PRIVATE faset_scene_view faset_runtime faset_assets)
add_test(NAME player_scene_contracts COMMAND faset_player_tests)
endif()
endif()
+33
View File
@@ -0,0 +1,33 @@
# Native plugins deliberately require the exact SDK, compiler, CRT and build.
file(GLOB_RECURSE FASET_SDK_INPUTS CONFIGURE_DEPENDS
"${PROJECT_SOURCE_DIR}/include/faset/*.hpp"
"${PROJECT_SOURCE_DIR}/include/faset/*.h"
"${PROJECT_SOURCE_DIR}/src/editor/*.cpp"
"${PROJECT_SOURCE_DIR}/src/authoring/*.cpp")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${FASET_SDK_INPUTS} "${PROJECT_SOURCE_DIR}/dependencies.lock.json")
set(FASET_SDK_SIGNATURE "${PROJECT_VERSION};${CMAKE_SYSTEM_NAME};${CMAKE_SYSTEM_PROCESSOR};${CMAKE_SIZEOF_VOID_P};${CMAKE_CXX_COMPILER_ID};${CMAKE_CXX_COMPILER_VERSION};${CMAKE_CXX_COMPILER_FRONTEND_VARIANT};${CMAKE_MSVC_RUNTIME_LIBRARY};${CMAKE_BUILD_TYPE};${CMAKE_CXX_FLAGS};${FASET_SANITIZERS}")
foreach(source IN LISTS FASET_SDK_INPUTS)
file(SHA256 "${source}" source_hash)
string(APPEND FASET_SDK_SIGNATURE ";${source_hash}")
endforeach()
file(SHA256 "${PROJECT_SOURCE_DIR}/dependencies.lock.json" dependency_hash)
string(SHA256 FASET_EDITOR_SDK_FINGERPRINT "${FASET_SDK_SIGNATURE};${dependency_hash}")
file(MAKE_DIRECTORY "${PROJECT_BINARY_DIR}/generated/faset/editor")
file(WRITE "${PROJECT_BINARY_DIR}/generated/faset/editor/sdk_build.h"
"#pragma once\n#define FASET_EDITOR_SDK_FINGERPRINT \"${FASET_EDITOR_SDK_FINGERPRINT}\"\n")
add_library(faset_editor_sdk INTERFACE)
target_include_directories(faset_editor_sdk INTERFACE "${PROJECT_SOURCE_DIR}/include" "${PROJECT_BINARY_DIR}/generated")
add_library(faset_editor_plugins STATIC src/editor/plugins.cpp)
target_link_libraries(faset_editor_plugins PUBLIC faset_editor_commands faset_editor_sdk PRIVATE ${CMAKE_DL_LIBS})
add_library(faset_example_plugin MODULE examples/extensions/beacon/Editor.cpp)
target_link_libraries(faset_example_plugin PRIVATE faset_editor_sdk nlohmann_json::nlohmann_json)
set_target_properties(faset_example_plugin PROPERTIES PREFIX "" LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/example-plugin" RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/example-plugin")
file(GENERATE OUTPUT "${PROJECT_BINARY_DIR}/example-plugin/beacon.faset-plugin.json" CONTENT
"{\n \"format\":\"faset.editor_plugin\",\n \"version\":1,\n \"id\":\"example.beacon\",\n \"module_version\":\"1.0.0\",\n \"kind\":\"editor\",\n \"api_version\":1,\n \"build_fingerprint\":\"${FASET_EDITOR_SDK_FINGERPRINT}\",\n \"library\":\"$<TARGET_FILE_NAME:faset_example_plugin>\",\n \"dependencies\":[]\n}\n")
if(BUILD_TESTING)
add_executable(faset_plugin_tests tests/plugin_tests.cpp)
target_link_libraries(faset_plugin_tests PRIVATE faset_editor_plugins faset_runtime)
target_compile_definitions(faset_plugin_tests PRIVATE FASET_TEST_PLUGIN_DIRECTORY="${PROJECT_BINARY_DIR}/example-plugin")
add_dependencies(faset_plugin_tests faset_example_plugin)
add_test(NAME editor_plugins COMMAND faset_plugin_tests)
endif()
+14 -3
View File
@@ -6,13 +6,24 @@ target_compile_features(faset_runtime PUBLIC cxx_std_20)
target_include_directories(faset_runtime PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../include) target_include_directories(faset_runtime PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../include)
target_link_libraries(faset_runtime PUBLIC nlohmann_json::nlohmann_json PRIVATE EnTT::EnTT box2d box3d) target_link_libraries(faset_runtime PUBLIC nlohmann_json::nlohmann_json PRIVATE EnTT::EnTT box2d box3d)
add_library(faset_gameplay STATIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay/Gameplay.cpp) set(FASET_GAMEPLAY_SOURCE_DIR "${PROJECT_SOURCE_DIR}/examples/gameplay" CACHE PATH "Directory containing the game's Gameplay.cpp and Gameplay.hpp")
if(NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.cpp" OR NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.hpp")
message(FATAL_ERROR "FASET_GAMEPLAY_SOURCE_DIR must contain Gameplay.cpp and Gameplay.hpp")
endif()
add_library(faset_gameplay STATIC "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.cpp")
add_library(Faset::Gameplay ALIAS faset_gameplay) add_library(Faset::Gameplay ALIAS faset_gameplay)
target_include_directories(faset_gameplay PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay) target_include_directories(faset_gameplay PUBLIC "${FASET_GAMEPLAY_SOURCE_DIR}")
target_link_libraries(faset_gameplay PUBLIC faset_runtime) target_link_libraries(faset_gameplay PUBLIC faset_runtime)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(faset_runtime_tests ${CMAKE_CURRENT_LIST_DIR}/../tests/runtime_tests.cpp) add_executable(faset_runtime_tests ${CMAKE_CURRENT_LIST_DIR}/../tests/runtime_tests.cpp)
target_link_libraries(faset_runtime_tests PRIVATE faset_runtime faset_gameplay) if(FASET_GAMEPLAY_SOURCE_DIR STREQUAL "${PROJECT_SOURCE_DIR}/examples/gameplay")
target_link_libraries(faset_runtime_tests PRIVATE faset_runtime faset_gameplay)
else()
add_library(faset_gameplay_test_fixture STATIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay/Gameplay.cpp)
target_include_directories(faset_gameplay_test_fixture PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay)
target_link_libraries(faset_gameplay_test_fixture PUBLIC faset_runtime)
target_link_libraries(faset_runtime_tests PRIVATE faset_runtime faset_gameplay_test_fixture)
endif()
add_test(NAME runtime_contracts COMMAND faset_runtime_tests) add_test(NAME runtime_contracts COMMAND faset_runtime_tests)
endif() endif()
+14
View File
@@ -0,0 +1,14 @@
if(BUILD_TESTING AND TARGET faset_runtime)
foreach(tutorial moving following spawning physics)
set(tutorial_dir "${PROJECT_SOURCE_DIR}/examples/tutorials/${tutorial}")
add_library(faset_tutorial_${tutorial} STATIC "${tutorial_dir}/Gameplay.cpp")
target_include_directories(faset_tutorial_${tutorial} PUBLIC "${tutorial_dir}")
target_link_libraries(faset_tutorial_${tutorial} PUBLIC faset_runtime)
add_executable(faset_tutorial_${tutorial}_tests "${PROJECT_SOURCE_DIR}/tests/runtime_tutorials.cpp")
target_link_libraries(faset_tutorial_${tutorial}_tests PRIVATE faset_tutorial_${tutorial})
target_compile_definitions(faset_tutorial_${tutorial}_tests PRIVATE
FASET_TUTORIAL_NAME="${tutorial}"
FASET_TUTORIAL_SCENE="${tutorial_dir}/scene.json")
add_test(NAME tutorial_${tutorial} COMMAND faset_tutorial_${tutorial}_tests)
endforeach()
endif()
+59
View File
@@ -0,0 +1,59 @@
# FreeType rasterization + HarfBuzz shaping. The default uses verified pinned archives.
option(FASET_USE_SYSTEM_TEXT_LIBRARIES "Use system FreeType/HarfBuzz instead of pinned SDK versions" OFF)
if(FASET_USE_SYSTEM_TEXT_LIBRARIES)
find_package(Freetype QUIET)
endif()
if(NOT TARGET Freetype::Freetype)
include(FetchContent)
set(FT_DISABLE_ZLIB ON CACHE BOOL "" FORCE)
set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE)
set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE)
set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE)
set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE)
faset_dependency(freetype)
if(NOT TARGET Freetype::Freetype)
add_library(Freetype::Freetype ALIAS freetype)
endif()
endif()
if(FASET_USE_SYSTEM_TEXT_LIBRARIES)
find_package(harfbuzz CONFIG QUIET)
endif()
if(TARGET harfbuzz::harfbuzz)
set(FASET_HARFBUZZ_TARGET harfbuzz::harfbuzz)
else()
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND AND FASET_USE_SYSTEM_TEXT_LIBRARIES)
pkg_check_modules(FASET_HARFBUZZ QUIET IMPORTED_TARGET harfbuzz)
endif()
if(TARGET PkgConfig::FASET_HARFBUZZ)
set(FASET_HARFBUZZ_TARGET PkgConfig::FASET_HARFBUZZ)
else()
include(FetchContent)
set(HB_HAVE_FREETYPE ON CACHE BOOL "" FORCE)
set(HB_BUILD_UTILS OFF CACHE BOOL "" FORCE)
set(HB_BUILD_SUBSET OFF CACHE BOOL "" FORCE)
faset_dependency(harfbuzz)
set(FASET_HARFBUZZ_TARGET harfbuzz)
endif()
endif()
add_library(faset_ui
${PROJECT_SOURCE_DIR}/src/ui/ui.cpp
${PROJECT_SOURCE_DIR}/src/ui/text.cpp
${PROJECT_SOURCE_DIR}/src/ui/font.cpp)
target_compile_features(faset_ui PUBLIC cxx_std_20)
target_include_directories(faset_ui PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_link_libraries(faset_ui PUBLIC faset_core PRIVATE Freetype::Freetype ${FASET_HARFBUZZ_TARGET})
if(BUILD_TESTING)
add_executable(faset_ui_tests ${PROJECT_SOURCE_DIR}/tests/ui_tests.cpp)
target_link_libraries(faset_ui_tests PRIVATE faset_ui)
target_compile_definitions(faset_ui_tests PRIVATE FASET_TEST_FONT="${PROJECT_SOURCE_DIR}/assets/fonts/NotoSans.ttf")
add_test(NAME ui_widgets COMMAND faset_ui_tests)
if(TARGET faset_render)
add_executable(faset_ui_render_test ${PROJECT_SOURCE_DIR}/tests/ui_render_test.cpp)
target_link_libraries(faset_ui_render_test PRIVATE faset_ui faset_render)
target_compile_definitions(faset_ui_render_test PRIVATE FASET_TEST_FONT="${PROJECT_SOURCE_DIR}/assets/fonts/NotoSans.ttf" FASET_UI_THEME="${PROJECT_SOURCE_DIR}/assets/ui/dark.json")
add_test(NAME ui_render COMMAND faset_ui_render_test ${CMAKE_BINARY_DIR}/ui-test.ppm)
set_tests_properties(ui_render PROPERTIES LABELS "gpu")
endif()
endif()
install(DIRECTORY ${PROJECT_SOURCE_DIR}/assets/fonts ${PROJECT_SOURCE_DIR}/assets/ui DESTINATION assets)
+16
View File
@@ -64,6 +64,22 @@
"url": "https://codeload.github.com/nothings/stb/tar.gz/2c980bb59875b0d32144a71867fbdebb2f77cd20", "url": "https://codeload.github.com/nothings/stb/tar.gz/2c980bb59875b0d32144a71867fbdebb2f77cd20",
"sha256": "9a955b1b49a4410088a2e0ee2a9c057c3c907d0c1d75454144cb980aca0ba515", "sha256": "9a955b1b49a4410088a2e0ee2a9c057c3c907d0c1d75454144cb980aca0ba515",
"license": "MIT OR Unlicense" "license": "MIT OR Unlicense"
},
"freetype": {
"repository": "https://github.com/freetype/freetype",
"version": "2.13.3",
"commit": "42608f77f20749dd6ddc9e0536788eaad70ea4b5",
"url": "https://codeload.github.com/freetype/freetype/tar.gz/42608f77f20749dd6ddc9e0536788eaad70ea4b5",
"sha256": "68ce87bb59ea209eb7350f41a94a27519ce16b37011b475a1e62d4abee154b66",
"license": "FTL"
},
"harfbuzz": {
"repository": "https://github.com/harfbuzz/harfbuzz",
"version": "10.4.0",
"commit": "3ef8709829a5884517ad91a97b32b9435b2f20d1",
"url": "https://codeload.github.com/harfbuzz/harfbuzz/tar.gz/3ef8709829a5884517ad91a97b32b9435b2f20d1",
"sha256": "61757682efefaa93a6eab7d988d139827e8f35071327cd93e23dc7fd77e0162b",
"license": "MIT"
} }
} }
} }
+54 -3
View File
@@ -28,9 +28,60 @@ Observed validation on Linux:
- MkDocs strict build passed with MkDocs 1.6.1 and Material 9.7.7. - MkDocs strict build passed with MkDocs 1.6.1 and Material 9.7.7.
The full editor, user-project build pipeline, MCP integration, standalone exports, The full editor, user-project build pipeline, MCP integration, standalone exports,
and Windows acceptance are still being implemented. This checkpoint is not the MVP release. and Windows acceptance were still being implemented at that checkpoint. Later progress is recorded below.
Known intermediate constraints include box-only physics colliders, root-level physics Known intermediate constraints include box-only physics colliders, root-level physics
objects, static glTF triangles/UV0, a conservative serial renderer, and unfinished objects, static glTF triangles/UV0, a conservative serial renderer, and then-unfinished
world-preserving authoring reparent operations. These remain implementation work or world-preserving authoring reparent operations (completed in checkpoint 2). These remain implementation work or
explicit profile limits to review during final acceptance. explicit profile limits to review during final acceptance.
## Checkpoint 2 — integrated Editor, gameplay iteration and export
Implemented and integrated:
- Separate Player and SchemaExporter executables; statically linked project gameplay,
configurable simulation settings, real contact-based grounded queries, and four
compiled scripting tutorials embedded directly into the English MkDocs manual.
- Shared authoring commands and a real stdio MCP server, optimistic revisions,
transactional retries, jobs/cancellation, recovery of unsaved documents, and
viewport capture in graphical sessions only.
- Cancellable native subprocess execution, incremental project builds, schema export,
binary scene/resource packaging, distinct Debug development and Release export
directories, immutable published generations and retained last-good builds.
- A native retained UI with pinned FreeType/HarfBuzz and bundled Noto Sans:
scene tree, schema Inspector, viewport camera/picking/gizmos, assets, diagnostics,
build/play controls, commands, recovery and startup-loaded extension action panels.
- Exact-build native Editor SDK, dependency validation, owner-bound registrations,
example Beacon runtime component/editor command/panel, and unknown-data preservation.
- Full world-preserving TRS reparent with explicit rejection of unsupported shear.
- Read-only cooked asset target for Player. Import/build/editor/MCP services remain
outside the shipping runtime dependency graph.
Observed validation:
- Integrated Linux Clang 21 build: 19 CTests pass, including GPU rendering, retained
widgets, Editor authoring interaction, actual plugin loading, real MCP stdio,
process/cook contracts, and all four compiled gameplay tutorials.
- Real 2D and imported-glTF 3D standalone exports render with zero Vulkan validation
errors on NVIDIA RTX 2080 Ti. Changing gameplay rebuilds metadata; failed C++
compilation preserves the last successful published build.
- Runtime/tutorial checks also pass with Clang 18; runtime ASan/UBSan checks pass.
- Unmodified Blender 4.5.3 exports through the optional add-on. Real Editor imports
preserve output IDs after rename/geometry edits, report deleted-output conflicts,
retain the last generation on failure, and preserve separate scene placement/color.
Reproduce with `tools/verify_blender_roundtrip.py --blender PATH --editor PATH`.
- Strict MkDocs build passes. Tutorial source snippets are compiled by CTest.
- Previous checkpoint headless Linux and Windows GitHub CI passed after portability
fixes. New checkpoint and full Windows graphical/export checks are separate work;
Linux validation does not imply Windows validation.
This is an implementation checkpoint, not an MVP release. Finished playable sample
projects, complete Windows graphical/export acceptance, fresh-install checks,
performance measurements, and final UX review remain. Standalone image import is
integrated but its dedicated PNG/JPEG edge-case checks are the next asset task.
The baseline profile currently uses box colliders, root-level rigid bodies, static
triangle glTF meshes/UV0, basic PBR/directional shadows and a conservative serial
Vulkan renderer. Advanced rendering and broader content profiles remain later work.
The generated UI reference determines visual direction only; architecture, behavior
and acceptance criteria remain authoritative.
+58
View File
@@ -0,0 +1,58 @@
# Native Editor extensions
Editor extensions are startup-loaded `.so`/`.dll` modules. They are trusted native
code inside the Editor process. Gameplay remains statically linked into the separate
Player; an Editor extension is never required by the exported game.
The initial SDK registers commands and small action panels. Inspector fields for
runtime components come from the separate gameplay SchemaExporter. Rich custom
widgets and a general marketplace/package manager are later work.
## Example: Beacon
`examples/extensions/beacon` contains:
- `Beacon.hpp`: a runtime component schema and rotating-object behavior.
- `Editor.cpp`: an Editor command and a panel that creates a Beacon in one transaction.
Include `Beacon.hpp` from your project's `Scripts/Gameplay.cpp`. Call
`beacon::register_behavior(runtime)` from `registerGameplay`, and append
`beacon::schema()` to the array returned by `schema()`. Build gameplay so the Editor
can load the new metadata. Read [the first behavior tutorial](../scripting/first-behavior.md)
for the complete gameplay registration convention.
The normal engine build produces `example-plugin/` inside its build directory.
Copy its `beacon.faset-plugin.json` and native library into your project's
`Plugins/` directory, then restart the Editor. The **Beacon tools** panel offers
**Add Beacon**. Its command is also discoverable through MCP as
`plugin_example_beacon_create` and requires a document ID.
Disabling the Editor extension removes its panel and command after restart. It does
not erase its saved component data. Removing the runtime registration leaves an
unknown component preserved by authoring; exporting that scene fails until the
runtime dependency is restored or the component is deliberately removed.
## Compatibility and ownership
A manifest includes module ID/version, `kind: editor`, API version, native library,
build fingerprint and dependencies with exact versions. The loader validates the
complete graph for missing dependencies and cycles before calling entry points.
A failed dependency prevents loading its dependents.
The fingerprint includes the SDK sources, dependency lock, platform, architecture,
compiler version, configuration and CRT settings. Rebuild a plugin for the exact
Editor SDK. Compatibility across arbitrary C++ builds is not promised. Reloading an
updated native module requires restarting the Editor.
`include/faset/editor/plugin_api.h` defines a small C interface. Borrowed UTF-8 JSON
strings are valid during a call; responses are copied through a receiving callback.
Each side frees its own allocations. Register only during startup and invoke the SDK
on the Editor thread. Commands must use their owning module's name prefix. Panels
can invoke owned commands; a `$document` argument resolves to the active authoring
document. No EnTT registry, live runtime world, or arbitrary C++ object pointer is
exposed as a scripting API.
The native plugin test loads the actual example module, creates a component,
checks Undo, runs its separate runtime behavior, unloads command registrations,
opens the saved scene without the package, and rejects incompatible/cyclic/missing
plugin dependencies.
+85
View File
@@ -0,0 +1,85 @@
# Use the Editor through MCP
Faset exposes the same authoring commands to its native interface and to MCP. MCP
runs in the **Editor**. It never provides access to a running game's entities, and
is not linked into the Player or SchemaExporter.
Start a headless server after building Faset:
```sh
build/linux-debug/faset_editor --project /absolute/path/to/game --mcp
```
Configure your MCP client to launch that executable with those arguments using a
stdio transport. On Windows select `build/windows-debug/faset_editor.exe`. Use
absolute paths, including the project path. Add `--gui` when the same process
should display the native Editor; a graphical session and supported GPU are then
required. A separately launched Editor process owns a separate in-memory session.
The transport uses newline-delimited JSON-RPC and the MCP `2025-06-18` lifecycle.
Initialize, send `notifications/initialized`, then discover commands with
`tools/list`. Standard output is reserved for protocol messages. See the official
[MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
and [tool contracts](https://modelcontextprotocol.io/specification/2025-06-18/server/tools).
## Authoring workflow
1. Use `faset_documents` or create/open a document.
2. Query `faset_schema` for stable type and field IDs and their constraints.
3. Query the document for its persistent ID and current revision.
4. Submit one `faset_scene_edit` batch with that revision.
5. Save with `faset_document_save`. Use Undo/Redo for authoring changes.
For example, the arguments to `faset_scene_edit` can be:
```json
{
"document": "REPLACE_WITH_DOCUMENT_ID",
"revision": 0,
"idempotency_key": "create-first-object",
"operations": [{"op": "entity.create", "name": "Player"}]
}
```
The command returns the new revision and scene with generated IDs. Repeating the
same batch and retry key in the same session returns its previous result. Reusing
the key for a different payload is an error. An outdated revision produces
`revision.conflict`; query the new state and decide how to apply the intended change.
Do not blindly retry a write using a fresh revision.
Each successful batch is one Undo step. A failed operation rejects the whole batch.
Manual Inspector edits use this same service, including revision checks.
## Jobs and capabilities
`faset_import`, `faset_build`, `faset_export`, and `faset_play` return job IDs.
Use `faset_job` or `faset_jobs` for progress, diagnostics and results.
`faset_job_cancel` requests cancellation; cancelling a build is separate from
undoing a document change. Failed compilation/import preserves the last successful
published generation.
`faset_capabilities` reports available services. `faset_editor_capture` is present
only in a graphical Editor. It returns an MCP image and can save a project-relative
PNG. A headless server has no screenshot tool. `faset_play_control` provides pause,
resume and single-step process controls; it cannot read or modify game entities.
`faset_recovery_list` and `faset_recovery_restore` expose crash recovery, including
scenes that have never been saved. Restoring an already open document requires its
current revision. Recovery refuses to overwrite an externally changed scene file.
## Single-command CLI
The CLI is useful for scripts that do not need an MCP session:
```sh
build/linux-debug/faset_editor --project /absolute/path/to/game \
--command '{"name":"faset_import","arguments":{"path":"Assets/door/manifest.json"}}' --wait
```
`--wait` follows a returned job until completion. Failed, cancelled and conflicting
jobs produce a nonzero exit code. This shell quoting example targets Bash; use the
appropriate argument quoting for your Windows shell.
Validation: unit tests cover protocol errors and shared authoring semantics.
`editor_mcp_stdio` launches the real Editor and verifies initialization, clean JSON
stdout, conflicts, retry behavior, Undo/Redo, saving, reopening, and orderly EOF.
+15 -5
View File
@@ -1,14 +1,14 @@
# Build from source # Build from source
!!! warning "Foundation checkpoint" !!! note "Implementation checkpoint"
These instructions initially cover the build foundation. The integrated editor, Linux Editor, Player and export integration are tested. Final Windows graphics/export
sample projects, and packaging steps are being added and verified during MVP implementation. acceptance is tracked separately in the implementation report.
## Linux prerequisites ## Linux prerequisites
The selected toolchain is C++20, CMake 3.25 or later, Ninja, and Clang. The selected toolchain is C++20, CMake 3.25 or later, Ninja, and Clang.
Graphical builds need Vulkan 1.3 headers/loader and a compatible driver. Graphical builds need Vulkan 1.3 headers/loader and a compatible driver.
SDL3 is built from a pinned source archive. SDL3, FreeType and HarfBuzz are built from pinned source archives.
On Ubuntu, install the native build tools before configuring: On Ubuntu, install the native build tools before configuring:
@@ -16,7 +16,7 @@ On Ubuntu, install the native build tools before configuring:
sudo apt install clang ninja-build cmake python3 python3-venv pkg-config \ sudo apt install clang ninja-build cmake python3 python3-venv pkg-config \
libvulkan-dev vulkan-validationlayers libx11-dev libxext-dev libxrandr-dev \ libvulkan-dev vulkan-validationlayers libx11-dev libxext-dev libxrandr-dev \
libxcursor-dev libxi-dev libxfixes-dev libxkbcommon-dev libwayland-dev \ libxcursor-dev libxi-dev libxfixes-dev libxkbcommon-dev libwayland-dev \
libfreetype-dev libharfbuzz-dev xvfb xvfb
``` ```
`xvfb` is used for automated window tests. A normal desktop session does not need it. `xvfb` is used for automated window tests. A normal desktop session does not need it.
@@ -30,6 +30,16 @@ cmake --build --preset linux-debug --parallel
ctest --preset linux-debug ctest --preset linux-debug
``` ```
Create a project and open the native Editor:
```sh
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3
```
Use **Build C++** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
The Player runs separately. Stop it before changing and rebuilding C++ gameplay.
See [MCP and CLI](../editor/mcp.md) for headless authoring and automation.
For an optimized build use `linux-release`. The `linux-sanitize` preset enables For an optimized build use `linux-release`. The `linux-sanitize` preset enables
AddressSanitizer and UndefinedBehaviorSanitizer for tests without the graphics backend. AddressSanitizer and UndefinedBehaviorSanitizer for tests without the graphics backend.
+1 -1
View File
@@ -7,7 +7,7 @@ and how those functions interact with scenes, physics, and the editor.
!!! warning "Development status" !!! warning "Development status"
MVP implementation is in progress. A planned feature is not a working feature. MVP implementation is in progress. A planned feature is not a working feature.
Individual guides state their prerequisites and validation status. The current Individual guides state their prerequisites and validation status. The current
foundation can be built and tested; a complete editor and game export are not yet available. Editor, gameplay tutorials and Linux export can be built and tested. Final Windows graphics/export acceptance and complete sample games are still in progress.
Start with [how C++ gameplay works](scripting/index.md), then read Start with [how C++ gameplay works](scripting/index.md), then read
[frame and physics updates](scripting/lifecycle.md). See [frame and physics updates](scripting/lifecycle.md). See
+70
View File
@@ -0,0 +1,70 @@
# Runtime API reference
Include `<faset/runtime/Runtime.hpp>` and use namespace `faset::runtime`. This is the implemented C++ surface used by the compiled tutorials. The runtime is sequential; call it from its owning thread.
## Register behavior
`void Runtime::registerBehavior(std::string componentType, Behavior behavior)` registers callbacks before scene loading. An empty or duplicate type, registration during a callback, and registration after entities have loaded are rejected.
`Behavior::Callback` is `std::function<void(Runtime&, EntityHandle, double)>`. Assign it to any of `onStart`, `fixedUpdate`, `update`, `lateUpdate`, and `onDestroy`. Unassigned members do nothing. `Behavior::onCollision` instead accepts `(Runtime&, EntityHandle, const CollisionEvent&)`.
See [callback order](lifecycle.md) and the complete [registration example](first-behavior.md).
## Resolve identity
- `EntityHandle find(const std::string& persistentId) const` returns a handle, or an empty handle when absent.
- `bool valid(EntityHandle) const noexcept` checks session, entity validity, and generation.
- `std::uint64_t session() const noexcept` identifies this runtime session, not the saved scene.
A handle contains `session`, `slot`, and `generation`. Its Boolean conversion says it is nonempty; it **does not** prove that the object is still alive. Call `valid` before using a retained handle. A handle from another runtime or an earlier `load` is rejected.
## Read configuration and poses
- `nlohmann::json fields(EntityHandle, const std::string& componentType) const` returns a configuration copy. It throws if the type is absent.
- `Transform transform(EntityHandle) const` returns a simulation-pose copy.
- `Transform presentation(EntityHandle) const` returns the pose prepared for display.
- `void setTransform(EntityHandle, const Transform&)` updates a non-physical object.
- `void setPresentation(EntityHandle, const Transform&)` updates presentation only, during `lateUpdate`.
`Transform` has `position`, `rotation`, and `scale`, each a `std::array<float, 3>`. Position is in metres; rotation is XYZ Euler radians with matrix composition `Rz * Ry * Rx`; scale is a multiplier. The pose is local to its scene parent. The renderer composes the hierarchy. The initial physics adapters require root objects.
These are values, not borrowed component pointers. Changing a returned copy has no effect until an appropriate setter is called. Presentation writes do not alter physics or the saved scene.
## Control a rigid body
- `Vec3 velocity(EntityHandle) const` reads metres per second. A 2D body returns Z = 0.
- `void setVelocity(EntityHandle, Vec3)` sets linear velocity; it does not take a displacement.
- `void applyImpulse(EntityHandle, Vec3)` applies an impulse at the centre and wakes the body.
- `void teleport(EntityHandle, const Transform&)` changes the pose discontinuously, wakes the body, and resets interpolation/contact-query history. It does not zero velocity.
- `bool grounded(EntityHandle) const` tests support using recent native contact normals.
These methods require a valid handle and a physics body. Ordinary `setTransform` is rejected for physical objects, including static and kinematic bodies. See [physics](physics.md) for dimensions, tolerances, and collider limits.
## Input and collision events
`InputState input() const noexcept` returns `horizontal`, `vertical`, `jumpPressed`, and `interactPressed`. The Player maps A/D and left/right arrows to horizontal input, W/S and up/down arrows to vertical input, Space to jump, and E to interact. The gameplay module decides what these actions do.
`CollisionEvent` contains `first`, `second`, and `began`. `onCollision` receives an event during post-physics delivery. Its reference lasts for that callback; copy the event if you need to retain it, then recheck retained handles before later use.
`const std::vector<CollisionEvent>& collisions() const noexcept` exposes the most recently completed tick's events. The vector is replaced on a later tick or scene replacement. Polling only once per rendered frame can miss an earlier tick in a multi-tick frame; use callbacks when every delivered event matters. This is contact begin/end notification, not a general event bus or a contact-normal query.
## Queue structural changes
- `void spawn(nlohmann::json entity)` queues an entity record with `id`, `name`, `parent`, and `components`.
- `void destroy(EntityHandle)` queues removal of the object and its descendants.
- `void addComponent(EntityHandle, nlohmann::json component)` queues a full component record.
- `void removeComponent(EntityHandle, const std::string& componentType)` queues removal by type.
Changes are applied in FIFO order at the next fixed-tick barrier. `spawn` does not return an immediately usable handle; use `find(id)` after application. A spawned child's parent must already exist when its command is applied. These commands are individual runtime operations, not an atomic authoring batch with Undo.
Immediate validation errors throw. Deferred failures are recorded in diagnostics; a stale command does not revive an object. IDs and component types must not collide. The [spawning example](examples.md#spawn-and-destroy-on-safe-boundaries) demonstrates the timing.
## Drive a world or a test
`Runtime(RuntimeConfig = {})` constructs the controller. `load(const nlohmann::json&)` validates and prepares a scene, creates its entities, then calls initial callbacks. Invalid scene data leaves the preceding world intact. `clear()` destroys the current entities and invalidates their session handles.
`FrameStats advance(double elapsedSeconds, InputState = {})` advances fixed ticks, frame callbacks, interpolation, and late callbacks. `singleStep(InputState = {})` advances one fixed tick. `setPaused(bool)` clears accumulated time and resets presentation history; `paused()` reports this local state. Do not call load/clear/advance recursively from a callback.
`RuntimeConfig` defaults to `fixedDelta = 1.0 / 60.0`, `maxCatchUpTicks = 4`, `physicsSubsteps = 4`, and `gravity = {0, -9.81f, 0}`. `FrameStats` reports fixed ticks performed, dropped time, interpolation fraction, and total tick count.
`snapshot()` returns a value snapshot for rendering; `snapshotJson()` provides its JSON representation. `diagnostics()` returns a read-only vector of runtime messages. These are native C++ APIs for the Player and tests, **not MCP endpoints**.
+50
View File
@@ -0,0 +1,50 @@
# More complete examples
Each folder below is a separate, buildable gameplay module with `Gameplay.hpp`, `Gameplay.cpp`, and `scene.json`. Select one using `FASET_GAMEPLAY_SOURCE_DIR`, as shown in [the first tutorial](first-behavior.md). The Player links that module statically.
## Follow an interpolated object
`examples/tutorials/following` moves an object during fixed updates. A camera follows its presentation pose during `lateUpdate`:
```cpp
--8<-- "examples/tutorials/following/Gameplay.cpp"
```
The camera resolves the saved target ID on each frame, handles its disappearance, reads `presentation(target)`, and writes only its own presentation pose. In this small scene both objects are roots, so their coordinate frames match. For objects under different parents, transform between coordinate frames explicitly; adding local positions from unrelated parents is incorrect.
The `tutorial_following` test advances one and a half fixed intervals. It checks that the camera follows the halfway presentation position, while its simulation pose is unchanged. It also removes the target to exercise missing-handle behavior.
## Spawn and destroy on safe boundaries
`examples/tutorials/spawning` creates a temporary sprite and removes it after its lifetime:
```cpp
--8<-- "examples/tutorials/spawning/Gameplay.cpp"
```
The spawner's `onStart` queues creation. The new object's `onStart` runs only when that command is applied. Its elapsed time is ordinary C++ state owned by the callbacks. A key contains all three handle fields, so a recycled slot or restarted session cannot accidentally reuse an older object's timer.
When the timer expires, `destroy` queues removal; the handle remains valid until the next barrier. `onDestroy` removes the stored timer entry. The example's `spawned_id` must be unique in the runtime scene: using the same value on several spawners produces a duplicate-ID diagnostic. A production spawner should choose an appropriate runtime ID policy.
This does not save the spawned object into the authoring document. The test checks deferred creation, eventual invalidation, and a fresh spawn after reloading the scene.
## Use a contact-based jump
`examples/tutorials/physics` contains the [complete physics controller](physics.md). Its test checks movement, a supported jump, rejection of a jump at the apex, and landing. The default example gameplay module also uses the same grounded query.
The query's slope/separation thresholds are intentionally small and explicit. Extend the gameplay controller when your game needs coyote time, jump buffering, climbing steps, or moving platforms; these are not automatically provided by naming a component “character”.
## Run the tutorial checks
After configuring a normal build with `BUILD_TESTING=ON`:
```bash
cmake --build build/linux-debug --target \
faset_tutorial_moving_tests faset_tutorial_following_tests \
faset_tutorial_spawning_tests faset_tutorial_physics_tests
ctest --test-dir build/linux-debug -R '^tutorial_' --output-on-failure
```
On Windows use the chosen Windows build directory. The four tests compile separate gameplay libraries and execute their actual callbacks. They require neither a graphics window nor the Editor. Running a tutorial through `faset_player` additionally checks rendering and platform integration and requires the documented Vulkan setup.
Schema declarations are tested for stable map-key/FieldId matching and defaults. They remain ordinary C++ source; the engine does not scan arbitrary C++ classes to create this API automatically.
+67
View File
@@ -0,0 +1,67 @@
# Your first behavior
This example moves a sprite along the X axis at two metres per second. It has no rigid body: the behavior owns its simulation pose.
## Build and run the complete example
First complete [the build setup](../getting-started/build.md), including the renderer dependencies. From the repository root on Linux:
```bash
cmake -S . -B build/tutorial-moving -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DFASET_GAMEPLAY_SOURCE_DIR="$PWD/examples/tutorials/moving"
cmake --build build/tutorial-moving --target faset_player faset_schema_exporter
build/tutorial-moving/faset_schema_exporter --output build/tutorial-moving/schema.json
build/tutorial-moving/faset_player --scene examples/tutorials/moving/scene.json
```
On Windows, use a Developer shell with `clang-cl`, the Windows SDK and the documented dependencies. Use `clang-cl` for both compiler options and an absolute path for `FASET_GAMEPLAY_SOURCE_DIR`; run `faset_player.exe` from the selected build directory.
The same directory contract is used by a project's `Scripts` folder. These commands select a complete gameplay module; they do not add its behavior to an unrelated module automatically.
## The module interface
```cpp
--8<-- "examples/tutorials/moving/Gameplay.hpp"
```
The header declares the two entry functions. `Runtime` is the game world API; its implementation and EnTT storage remain inside the engine.
## The implementation
```cpp
--8<-- "examples/tutorials/moving/Gameplay.cpp"
```
Read the callback from top to bottom:
1. `game` is the current runtime, and `self` is the object carrying `tutorial.move_x`.
2. `delta` is this frame's elapsed time in **seconds**.
3. `settings.value("speed", 2.0f)` reads configuration and supplies a fallback if the field is absent.
4. `transform` gives a pose copy. Multiplying metres per second by seconds gives a displacement in metres.
5. `setTransform` publishes the changed non-physical pose.
The `[](...) { ... }` expression is a C++ lambda: a function stored in `Behavior::update`. Empty brackets mean it captures no local variables. `registerBehavior` takes ownership of the callback object. Register before calling `load`; registration after a world has loaded is rejected.
The `schema()` function describes editable configuration. It does not create a runtime object. `tutorial.move_x` is the stable `TypeId`; `speed` is a stable `FieldId` within that type. Keep these IDs when changing a display label. Changing a field's meaning or units needs an explicit data migration, not just a new label.
## Attach the behavior
The example scene is a complete, loadable document:
```json
--8<-- "examples/tutorials/moving/scene.json"
```
The sprite is visible because it has `faset.sprite`. It moves because it also has `tutorial.move_x`. The configuration field is `speed`; the type string must match the registration exactly. `rotation` uses radians, and the default coordinate system is Y-up.
After your project's schema is exported and loaded by the Editor, the type can be described through the same authoring schema used by the Inspector. The direct Player command above is useful before building an Editor workflow around your component.
## Make a change and verify it
Change the scene's `speed` to `-2`: the object moves left. Change the C++ callback or schema: stop the Player, rebuild, regenerate the schema, then launch a new session. There is no automatic C++ hot reload.
The `tutorial_moving` CTest checks that both 30 Hz and 60 Hz frame sequences move the object two metres in one second. It checks the resulting pose, rather than only checking that the program starts.
Common mistakes are forgetting `setTransform` after editing the copy, writing the wrong component type string, and attaching a rigid body while still using `setTransform`. The last case produces a runtime diagnostic: physics owns that body's pose. Continue with [physics movement](physics.md) for the correct API.
+28 -29
View File
@@ -1,41 +1,40 @@
# C++ gameplay # C++ gameplay
In the first version of Faset, a "script" is C++ gameplay code compiled into your game. In Faset, a gameplay script is **C++ compiled into the Player**. You write ordinary functions and register the callbacks an object needs. There is no C++ interpreter or live replacement of compiled classes. Stop Play, rebuild, export the schema, and start a new Player session.
It is not an interpreted text file. The gameplay library is statically linked into
a separate Player executable.
The intended iteration cycle is: Lua is planned for a later stage. The APIs and tutorials in this section describe the C++ implementation available now.
1. Stop Play. ## Start here
2. Edit your C++ behavior or system.
3. Build the changed code and export its property schema.
4. Start a new Player session.
The Editor reads a schema generated by a separate SchemaExporter. It does not load 1. Read [Your first behavior](first-behavior.md) and run the moving-object example.
your gameplay library into its own process. A gameplay crash therefore does not 2. Learn [when callbacks run](lifecycle.md) before mixing frame updates and physics.
automatically crash the Editor. Editor native extensions have a different lifecycle 3. Build a [physics character](physics.md) that can move and jump from the floor.
and run inside the Editor process. 4. Try [following, spawning, and timed destruction](examples.md).
5. Keep the [runtime API reference](api.md) nearby while writing code.
!!! note "API examples are added with implementation" The complete tutorial modules are compiled and executed by CTest. The code blocks include those source files directly, so the manual does not maintain separate, untested copies.
This page describes the accepted execution model. Exact function signatures and
complete examples will be documented alongside compiling runtime examples, rather
than presenting proposed APIs as available functions.
## Behaviors and systems ## What belongs to your module
A behavior gives an individual object lifecycle callbacks. A system operates on a A gameplay directory contains `Gameplay.hpp` and `Gameplay.cpp`. It provides two functions in `faset::gameplay`:
set of objects with matching components. Both use the same runtime state; the visual
scene and Inspector are the authoring view of that state.
Persistent scene IDs and runtime handles are different. A scene ID survives saving - `registerGameplay(runtime::Runtime&)` registers executable behavior callbacks.
and reopening. A runtime handle belongs to a particular world/session and can become - `schema()` returns a JSON array of component descriptions: stable type and field IDs, versions, defaults, constraints, and Inspector hints.
invalid after an object is removed. Do not store raw component pointers across
structural changes or treat a runtime handle as a save-file ID.
## Physics ownership The Player calls registration before loading a scene. The separate SchemaExporter calls `schema()` without creating a game world or running gameplay callbacks. The Editor reads the resulting declaration; it does not load the gameplay binary into the Editor process.
Physics owns the position of a dynamic rigid body. Move it with the supported physics A scene object receives a behavior by containing a component whose `type` matches the string passed to `registerBehavior`. Registering a behavior does not attach it to every object. A component can contain data without having any callbacks.
commands instead of writing its presentation transform. A camera or other visual-only
object can follow the interpolated result without modifying the simulation.
Continue with [Frame and physics updates](lifecycle.md). ## Data, poses, and state
`fields(self, type)` returns a **copy of component configuration**. Editing that copy changes neither the saved scene nor the runtime configuration. `transform(self)` returns a copy of the current simulation pose; pass the changed copy to `setTransform` for a non-physical object. A rigid body uses `setVelocity`, `applyImpulse`, or an explicit `teleport` instead.
Ordinary C++ state can be captured by callbacks. The spawning tutorial shows state indexed by the full runtime handle and cleaned up in `onDestroy`. Do not capture a reference to a local variable that will disappear after `registerGameplay` returns. Use owned state, or ensure the referenced object outlives the runtime.
Scene IDs are saved strings. `EntityHandle` is a temporary reference containing a session, slot, and generation. It must not be written into a save file. Resolve a scene ID with `find`, check `valid`, and expect old handles to stop working after removal or a new Play session.
## Current boundaries
These examples use per-object callbacks and the implemented typed pose/physics API. They do not provide a universal binding for arbitrary C++ classes, a public EnTT registry, or a general parallel-system scheduler. The runtime is sequential and has one owning thread.
MCP belongs to the Editor's authoring, import, build, and process-control services. It does not invoke runtime methods or inspect the live game world. A C++ gameplay change requires the same rebuild whether a person or an agent edited the source.
+47 -33
View File
@@ -1,47 +1,61 @@
# Frame and physics updates # Frame and physics updates
!!! note "Execution contract" The C++ member names are `onStart`, `fixedUpdate`, `update`, `lateUpdate`, and `onDestroy`. Design discussions may call the corresponding phases OnStart, FixedUpdate, Update, LateUpdate, and OnDestroy. Use the **camelCase member names** in code.
This page describes the accepted runtime contract. The runnable callback examples
and test results are added as the runtime implementation becomes available.
## Choose the right callback Each ordinary callback receives `(Runtime& game, EntityHandle self, double delta)`. Register only the callbacks you need. `onStart` and `onDestroy` receive a zero `delta`; update callbacks receive seconds. `onCollision` has a separate event signature described in the [API reference](api.md).
- `OnStart`: initialize a behavior once its object and components exist. ## Object lifetime
- `FixedUpdate`: update simulation logic before a physics step.
- `Update`: run frame-based gameplay once per rendered frame.
- `LateUpdate`: update cameras and dependent visual objects after presentation interpolation.
- `OnDestroy`: release subscriptions and other behavior-owned state before its handle is invalidated.
The default simulation interval is 1/60 second. A rendered frame may contain zero, `onStart` runs once after an object and its components exist. All objects in the initial scene are created before their initial callbacks run. A spawn queued by `onStart` becomes visible at the next fixed-tick barrier, not during the callback that requested it.
one, or several fixed ticks. Frame rate and physics rate are not the same quantity.
## Fixed tick order `onDestroy` runs while that object's handle and allowed component data are still valid. Clean up subscriptions or external C++ state there. After removal, `valid(oldHandle)` returns false. Removing a behavior component also runs that component's `onDestroy`. Clearing or replacing a scene runs destruction callbacks; a new scene uses a new session identity.
1. Apply structural commands queued by earlier work. Registering a callback does not make captured pointers safe. A lambda that stores a reference to a stack variable in `registerGameplay` will outlive that variable. The [timed-despawn example](examples.md#spawn-and-destroy-on-safe-boundaries) uses shared ownership for captured state and removes each object's entry on destruction.
2. Deliver tick input and call `FixedUpdate`.
3. Apply physics commands and step the 2D and 3D worlds.
4. Read back transforms and queue collision events.
5. Run reactions after physics.
Object creation/removal and component addition/removal are deferred to the beginning ## One fixed tick
of the next fixed tick. This prevents a callback from invalidating the collection
currently being processed. New objects follow the same initialization rules as objects
loaded from a scene.
After the fixed ticks, the frame runs `Update`, prepares interpolated presentation The default interval is 1/60 second. A rendered frame may contain zero, one, or several fixed ticks. For each tick the runtime:
transforms, calls `LateUpdate`, and produces the render snapshot.
## Avoid frame-rate-dependent movement 1. Applies structural commands queued by earlier work, in FIFO order.
2. Makes tick input available and calls `fixedUpdate`.
3. Steps the scene's Box2D or Box3D world with its configured substeps.
4. Reads physical poses back and delivers collision events to `onCollision` callbacks.
A speed is a distance per second. Multiply it by the callback's elapsed seconds when `spawn`, `destroy`, `addComponent`, and `removeComponent` queue structural changes. A command queued while this barrier or a callback runs waits until the **next** tick. This avoids invalidating the entity collection currently being visited. Runtime structural commands do not create an Editor Undo action or modify a saved scene.
calculating a displacement. Do not multiply a velocity by elapsed time before assigning
it to a physics velocity API; the physics step performs that integration.
## Overload and pause A velocity is metres per second: assign it directly. A manually calculated displacement is speed multiplied by `delta`. The [physics controller](physics.md) demonstrates this distinction.
The initial catch-up limit is four fixed ticks per frame. Excess whole intervals are ## One rendered frame
dropped with a diagnostic rather than making the physics step arbitrarily large.
This is a local-game policy, not a guarantee of deterministic network simulation.
Pausing clears accumulated time. Single-step advances exactly one simulation tick. After its fixed ticks, the runtime calls `update` once. It then prepares presentation transforms, calls `lateUpdate`, and makes the final snapshot available to rendering.
Interpolation history is reset for a new session, spawn, or teleport.
For interpolation, the runtime blends the previous and current completed simulation poses using the accumulator fraction. Position and scale are interpolated linearly; rotation follows the shortest quaternion path. This normally displays a pose up to one fixed tick behind the latest simulated state. It is not prediction of a future physics pose.
Use `presentation(target)` in `lateUpdate` when a camera follows a physical object. Following `transform(target)` instead would follow the discrete simulation pose and can cause visible judder. Use `setPresentation` for the camera's visual pose; this method is permitted only during `lateUpdate` and does not write back into physics.
A non-physical pose changed in `update` is presented directly for that frame. Physical bodies reject ordinary `setTransform`; use an explicit teleport when discontinuous motion is intended. Spawn, teleport, scene replacement, and pause transitions reset the relevant interpolation history.
## Input, pause, and overload
`input()` supplies held horizontal/vertical axes and one-shot jump/interact edges. In `fixedUpdate`, an edge survives a rendered frame with no fixed tick and is consumed once, even when the next frame catches up several ticks. In `update`, input is the current frame's input. Consume a gameplay action in one chosen phase so your own code does not apply it twice.
The default catch-up limit is four ticks per frame. Excess whole intervals are dropped and reported as `dropped_time`; the fractional remainder is retained. Physics `delta` is not enlarged to compensate. This is a local-game policy, not a lockstep or rollback guarantee.
Player keys `P` and `N` pause and single-step. Pause clears accumulated wall time. One step advances one fixed tick and produces a current presentation pose. Resuming does not simulate the time spent paused.
## Configure simulation
The Player reads this optional object from the scene document. The settings are used by ordinary Play and `--validate`:
```json
{
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [0, -9.81, 0]
}
}
```
This is an excerpt, not a complete scene. The tutorial scenes contain complete examples. `fixed_delta` is seconds, gravity is metres per second squared, and substeps are solver subdivisions inside one fixed tick. These do not create additional gameplay callbacks. Invalid configuration is rejected before the Player starts. Editing these JSON settings is implemented; an Editor settings panel should only be relied on where the current UI exposes it.
+49
View File
@@ -0,0 +1,49 @@
# Move and jump with physics
A physical object's final pose belongs to Box2D or Box3D. Your behavior supplies intent through velocity, impulse, or an explicit teleport. It must not write a presentation position back into a rigid body each frame.
## Run the controller
Select `examples/tutorials/physics` as `FASET_GAMEPLAY_SOURCE_DIR`, build `faset_player`, and launch it with `--scene examples/tutorials/physics/scene.json`. Use the commands from [the first tutorial](first-behavior.md), changing the folder and build directory.
Press **A/D** or the left/right arrows to move, and **Space** to jump. The built-in Player supplies W/S as a vertical input axis too; this particular 2D controller deliberately uses only the horizontal axis. `P` pauses, `N` advances one tick while paused, and Escape closes the Player.
## Complete controller code
```cpp
--8<-- "examples/tutorials/physics/Gameplay.cpp"
```
The callback starts with the current velocity so it preserves the solver's vertical motion. It replaces only the horizontal component. An accepted jump replaces vertical velocity with `jump_speed`.
Do **not** multiply the assigned velocity by `delta`. The solver integrates metres per second over the fixed interval. `applyImpulse` is different: it applies a momentum impulse and its effect depends on mass. The adapter uses the body's centre and wakes it.
The collision callback is delivered after the physics step on the runtime's owning thread. It receives copied handles and a begin/end flag, not pointers into the native solver. This example prints a message when contact begins. Both objects can receive their own callback if both have registered behaviors.
## What grounded means
`grounded(self)` examines contact manifolds from the last completed physics step. A contact counts as support when its normal points sufficiently against gravity (dot product greater than 0.6) and at least one contact point is within 0.02 metres. With zero gravity, the query uses Y-up. The query supports both physics adapters.
This distinguishes a floor from a wall and from the top of a jump. **Zero vertical speed is not a ground test**: vertical speed is also near zero at the apex. The tests explicitly try to jump there and check that another upward impulse is not created.
New bodies have no support result before a physics step. Teleporting invalidates the old contact result until the next step. The query is a small support test; it is not a full character motor with step climbing, coyote time, jump buffering, moving-platform attachment, or a capsule controller.
## Scene components and units
The controller's scene includes a static floor and a dynamic box:
```json
--8<-- "examples/tutorials/physics/scene.json"
```
Use `faset.rigid_body_2d` in a 2D scene and `faset.rigid_body_3d` in a 3D scene. Each currently creates a box collider. `body_type` accepts `static`, `dynamic`, or `kinematic`. `half_extents` contains half the box dimensions in metres: two values for 2D, three for 3D. The object's absolute scale multiplies those extents at creation.
Density must be positive; friction is nonnegative; restitution is between zero and one. `linear_velocity` uses metres per second, `gravity_scale` scales world gravity for that body, and `category_bits`/`mask_bits` filter collisions. Rotation uses radians; a 2D rigid body rotates only around Z.
Initial adapters require physical bodies to be **root scene objects**. A parent transform is not silently baked into a rigid body's simulation frame. Collider scale cannot be changed through teleport; remove/re-add the body through the deferred component API when rebuilding its shape. More collider types and articulated character motors are separate work.
## Teleport and failure handling
For an intentional discontinuity, get a pose copy, change its position, and call `teleport(self, pose)`. It resets interpolation and wakes the body. It preserves the body's velocity; call `setVelocity(self, {0, 0, 0})` as well when resetting motion is intended.
A missing body or stale handle makes the physics accessor throw. Exceptions raised inside gameplay callbacks are recorded in `Runtime::diagnostics()` and printed by the Player; later phases continue. Fix the error instead of using exceptions as a normal ground test. A behavior that controls physics should be attached only to an object with the matching rigid-body component.
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace beacon {
inline nlohmann::json schema() {
return {{"id", "example.beacon"},
{"name", "Beacon"},
{"version", 1},
{"fields",
{{"speed",
{{"id", "speed"},
{"name", "Rotation speed"},
{"type", "number"},
{"default", 1.0},
{"units", "rad/s"}}}}}};
}
inline void register_behavior(faset::runtime::Runtime& runtime) {
faset::runtime::Behavior behavior;
behavior.fixedUpdate = [](faset::runtime::Runtime& world, faset::runtime::EntityHandle self,
double dt) {
auto pose = world.transform(self);
pose.rotation[1] +=
world.fields(self, "example.beacon").value("speed", 1.0f) * static_cast<float>(dt);
world.setTransform(self, pose);
};
runtime.registerBehavior("example.beacon", std::move(behavior));
}
} // namespace beacon
+119
View File
@@ -0,0 +1,119 @@
#include <array>
#include <faset/editor/plugin_api.h>
#include <iomanip>
#include <nlohmann/json.hpp>
#include <random>
#include <sstream>
#include <stdexcept>
namespace {
using Json = nlohmann::json;
struct State {
const FasetEditorHost* host;
};
void collect(void* target, const char* data, uint64_t size) {
static_cast<std::string*>(target)->append(data, size);
}
Json invoke(State& state, const char* command, const Json& arguments) {
std::string output;
const auto input = arguments.dump();
const auto status =
state.host->invoke_command(state.host->context, command, input.c_str(), collect, &output);
auto result = Json::parse(output);
if (status != 0)
throw std::runtime_error(result.value("message", std::string("Editor command failed")));
return result;
}
std::string id() {
std::random_device random;
std::ostringstream stream;
stream << std::hex << std::setfill('0');
for (int i = 0; i < 4; ++i)
stream << std::setw(8) << random();
return stream.str();
}
int create(void* user, const char* input, FasetWrite write, void* receiver) {
try {
auto& state = *static_cast<State*>(user);
const auto arguments = Json::parse(input);
const auto document =
invoke(state, "faset_document_query", {{"document", arguments.at("document")}});
const auto schema = invoke(state, "faset_schema", Json::object());
bool known = false;
for (const auto& type : schema.at("types"))
if (type.at("id") == "example.beacon")
known = true;
if (!known)
throw std::runtime_error("Include Beacon.hpp in gameplay, register its schema and "
"behavior, then Build before adding a Beacon");
const auto entity = id();
Json record = {
{"id", entity},
{"name", "Beacon"},
{"parent", nullptr},
{"components",
Json::array(
{{{"id", id()},
{"type", "faset.transform"},
{"version", 1},
{"fields",
{{"position", {0, 1, 0}}, {"rotation", {0, 0, 0}}, {"scale", {1, 1, 1}}}}},
{{"id", id()},
{"type", "faset.mesh"},
{"version", 1},
{"fields",
{{"asset", ""}, {"primitive", "cube"}, {"color", {0.68, 0.55, 0.95, 1}}}}},
{{"id", id()},
{"type", "example.beacon"},
{"version", 1},
{"fields", {{"speed", 1.0}}}}})}};
const auto result =
invoke(state, "faset_scene_edit",
{{"document", document.at("id")},
{"revision", document.at("revision")},
{"operations", Json::array({{{"op", "entity.create"}, {"entity", record}}})}})
.dump();
write(receiver, result.data(), result.size());
return 0;
} catch (const std::exception& error) {
const auto output = Json{{"code", "beacon.create"}, {"message", error.what()}}.dump();
write(receiver, output.data(), output.size());
return 1;
}
}
void shutdown(void* user) {
delete static_cast<State*>(user);
}
} // namespace
extern "C" FASET_PLUGIN_EXPORT int faset_editor_plugin(const FasetEditorHost* host,
FasetEditorPlugin* plugin) {
if (!host || !plugin || host->api_version != FASET_EDITOR_API_VERSION ||
host->struct_size != sizeof(FasetEditorHost) ||
std::string(host->build_fingerprint) != FASET_EDITOR_SDK_FINGERPRINT)
return 1;
try {
auto* state = new State{host};
*plugin = {FASET_EDITOR_API_VERSION, sizeof(FasetEditorPlugin),
FASET_EDITOR_SDK_FINGERPRINT, state, shutdown};
const auto descriptor = Json{
{"name", "plugin_example_beacon_create"},
{"description", "Create a rotating Beacon using one authoring transaction."},
{"inputSchema",
{{"type", "object"},
{"properties", {{"document", {{"type", "string"}}}}},
{"required", {"document"}},
{"additionalProperties",
false}}}}.dump();
if (host->register_command(host->context, descriptor.c_str(), create, state) != 0)
return 1;
const auto panel = Json{{"id", "example.beacon.tools"},
{"title", "Beacon tools"},
{"action", "Add Beacon"},
{"command", "plugin_example_beacon_create"},
{"arguments", {{"document", "$document"}}}}
.dump();
return host->register_panel(host->context, panel.c_str());
} catch (...) {
return 1;
}
}
+52 -33
View File
@@ -6,46 +6,65 @@
namespace faset::gameplay { namespace faset::gameplay {
void registerGameplay(runtime::Runtime& engine) { void registerGameplay(runtime::Runtime& engine) {
runtime::Behavior character; runtime::Behavior character;
character.fixedUpdate=[](runtime::Runtime& world,runtime::EntityHandle self,double) { character.fixedUpdate = [](runtime::Runtime& world, runtime::EntityHandle self, double) {
const auto fields=world.fields(self,"gameplay.character"); const auto fields = world.fields(self, "gameplay.character");
auto velocity=world.velocity(self);const auto input=world.input(); auto velocity = world.velocity(self);
velocity[0]=input.horizontal*fields.value("speed",4.0f); const auto input = world.input();
// A minimal demo controller: jump only near zero vertical velocity. velocity[0] = input.horizontal * fields.value("speed", 4.0f);
// A production grounded controller needs contact normals / a ground query. if (input.jumpPressed && world.grounded(self))
if(input.jumpPressed&&std::abs(velocity[1])<0.1f)velocity[1]=fields.value("jump_speed",5.0f); velocity[1] = fields.value("jump_speed", 5.0f);
world.setVelocity(self,velocity); world.setVelocity(self, velocity);
}; };
engine.registerBehavior("gameplay.character",std::move(character)); engine.registerBehavior("gameplay.character", std::move(character));
runtime::Behavior door; runtime::Behavior door;
auto open=std::make_shared<std::map<std::pair<std::uint64_t,std::uint32_t>,bool>>(); auto open = std::make_shared<std::map<std::pair<std::uint64_t, std::uint32_t>, bool>>();
door.onStart=[open](runtime::Runtime&,runtime::EntityHandle self,double){(*open)[{self.session,self.slot}]=false;}; door.onStart = [open](runtime::Runtime&, runtime::EntityHandle self, double) {
door.onDestroy=[open](runtime::Runtime&,runtime::EntityHandle self,double){open->erase({self.session,self.slot});}; (*open)[{self.session, self.slot}] = false;
door.fixedUpdate=[open](runtime::Runtime& world,runtime::EntityHandle self,double dt) {
const auto fields=world.fields(self,"gameplay.door");
auto pose=world.transform(self);
auto& opened=(*open)[{self.session,self.slot}];
if(world.input().interactPressed)opened=!opened;
const float target=opened?fields.value("open_angle",1.5707963f):fields.value("closed_angle",0.0f);
const float distance=target-pose.rotation[1];
const float amount=std::max(0.0f,fields.value("speed",1.5f))*static_cast<float>(dt);
pose.rotation[1]+=std::clamp(distance,-amount,amount);
world.setTransform(self,pose);
}; };
engine.registerBehavior("gameplay.door",std::move(door)); door.onDestroy = [open](runtime::Runtime&, runtime::EntityHandle self, double) {
open->erase({self.session, self.slot});
};
door.fixedUpdate = [open](runtime::Runtime& world, runtime::EntityHandle self, double dt) {
const auto fields = world.fields(self, "gameplay.door");
auto pose = world.transform(self);
auto& opened = (*open)[{self.session, self.slot}];
if (world.input().interactPressed)
opened = !opened;
const float target =
opened ? fields.value("open_angle", 1.5707963f) : fields.value("closed_angle", 0.0f);
const float distance = target - pose.rotation[1];
const float amount = std::max(0.0f, fields.value("speed", 1.5f)) * static_cast<float>(dt);
pose.rotation[1] += std::clamp(distance, -amount, amount);
world.setTransform(self, pose);
};
engine.registerBehavior("gameplay.door", std::move(door));
} }
nlohmann::json schema() { nlohmann::json schema() {
// Explicit declarations shared by Player and SchemaExporter. This function // Explicit declarations shared by Player and SchemaExporter. This function
// constructs descriptions only: no Runtime, physics world or lifecycle. // constructs descriptions only: no Runtime, physics world or lifecycle.
return nlohmann::json::array({ return nlohmann::json::array(
{{"id","gameplay.character"},{"version",1},{"name","Character"},{"fields",{ {{{"id", "gameplay.character"},
{"speed",{{"id","speed"},{"type","number"},{"default",4.0},{"min",0.0}}}, {"version", 1},
{"jump_speed",{{"id","jump_speed"},{"type","number"},{"default",5.0},{"min",0.0}}}}}}, {"name", "Character"},
{{"id","gameplay.door"},{"version",1},{"name","Door"},{"fields",{ {"fields",
{"open_angle",{{"id","open_angle"},{"type","number"},{"default",1.5707963},{"units","rad"}}}, {{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 4.0}, {"min", 0.0}}},
{"closed_angle",{{"id","closed_angle"},{"type","number"},{"default",0.0},{"units","rad"}}}, {"jump_speed",
{"speed",{{"id","speed"},{"type","number"},{"default",1.5},{"min",0.0},{"units","rad/s"}}}}}} {{"id", "jump_speed"}, {"type", "number"}, {"default", 5.0}, {"min", 0.0}}}}}},
}); {{"id", "gameplay.door"},
} {"version", 1},
{"name", "Door"},
{"fields",
{{"open_angle",
{{"id", "open_angle"}, {"type", "number"}, {"default", 1.5707963}, {"units", "rad"}}},
{"closed_angle",
{{"id", "closed_angle"}, {"type", "number"}, {"default", 0.0}, {"units", "rad"}}},
{"speed",
{{"id", "speed"},
{"type", "number"},
{"default", 1.5},
{"min", 0.0},
{"units", "rad/s"}}}}}}});
} }
} // namespace faset::gameplay
+1 -1
View File
@@ -4,4 +4,4 @@
namespace faset::gameplay { namespace faset::gameplay {
void registerGameplay(runtime::Runtime& runtime); void registerGameplay(runtime::Runtime& runtime);
nlohmann::json schema(); nlohmann::json schema();
} } // namespace faset::gameplay
+43
View File
@@ -0,0 +1,43 @@
#include "Gameplay.hpp"
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior motion;
motion.fixedUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double delta) {
auto pose = game.transform(self);
pose.position[0] += game.fields(self, "tutorial.fixed_move").value("speed", 2.0f) *
static_cast<float>(delta);
game.setTransform(self, pose);
};
world.registerBehavior("tutorial.fixed_move", std::move(motion));
runtime::Behavior follow;
follow.lateUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double) {
const auto settings = game.fields(self, "tutorial.follow");
const auto target = game.find(settings.value("target", std::string{}));
if (!game.valid(target))
return; // Target may be absent or destroyed.
const auto targetPose = game.presentation(target); // Already interpolated.
const auto offset = settings.at("offset").get<runtime::Vec3>();
auto cameraPose = game.presentation(self);
for (int axis = 0; axis < 3; ++axis)
cameraPose.position[axis] = targetPose.position[axis] + offset[axis];
game.setPresentation(self, cameraPose); // Does not write the simulation pose.
};
world.registerBehavior("tutorial.follow", std::move(follow));
}
nlohmann::json schema() {
return nlohmann::json::array(
{{{"id", "tutorial.fixed_move"},
{"version", 1},
{"name", "Fixed movement"},
{"fields", {{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 2.0}}}}}},
{{"id", "tutorial.follow"},
{"version", 1},
{"name", "Follow presentation"},
{"fields",
{{"target", {{"id", "target"}, {"type", "entity_ref"}, {"default", "actor"}}},
{"offset", {{"id", "offset"}, {"type", "vec3"}, {"default", {0, 0, 10}}}}}}}});
}
} // namespace faset::gameplay
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+126
View File
@@ -0,0 +1,126 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-following",
"name": "Following tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "actor",
"name": "actor",
"parent": null,
"components": [
{
"id": "actor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "actor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.7,
0.9,
1
],
"size": [
1,
1
]
}
},
{
"id": "actor-tutorial.fixed_move",
"type": "tutorial.fixed_move",
"version": 1,
"fields": {
"speed": 2
}
}
]
},
{
"id": "camera",
"name": "camera",
"parent": null,
"components": [
{
"id": "camera-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
10
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "camera-faset.camera",
"type": "faset.camera",
"version": 1,
"fields": {
"fov": 60,
"near": 0.1,
"far": 100
}
},
{
"id": "camera-tutorial.follow",
"type": "tutorial.follow",
"version": 1,
"fields": {
"target": "actor",
"offset": [
0,
0,
10
]
}
}
]
}
],
"instances": []
}
+31
View File
@@ -0,0 +1,31 @@
#include "Gameplay.hpp"
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior mover;
mover.update = [](runtime::Runtime& game, runtime::EntityHandle self, double delta) {
// fields() returns a configuration copy. transform() returns a live pose copy.
const auto settings = game.fields(self, "tutorial.move_x");
const float speed = settings.value("speed", 2.0f); // metres per second
auto pose = game.transform(self);
pose.position[0] += speed * static_cast<float>(delta);
game.setTransform(self, pose); // This object has no rigid body.
};
world.registerBehavior("tutorial.move_x", std::move(mover));
}
nlohmann::json schema() {
// The FieldId is the map key. Keep it stable if you change a display name.
return nlohmann::json::array({{{"id", "tutorial.move_x"},
{"version", 1},
{"name", "Move along X"},
{"fields",
{{"speed",
{{"id", "speed"},
{"type", "number"},
{"default", 2.0},
{"min", -20.0},
{"max", 20.0},
{"units", "m/s"}}}}}}});
}
} // namespace faset::gameplay
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+74
View File
@@ -0,0 +1,74 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-moving",
"name": "Moving tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "actor",
"name": "actor",
"parent": null,
"components": [
{
"id": "actor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "actor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.7,
0.9,
1
],
"size": [
1,
1
]
}
},
{
"id": "actor-tutorial.move_x",
"type": "tutorial.move_x",
"version": 1,
"fields": {
"speed": 2
}
}
]
}
],
"instances": []
}
+43
View File
@@ -0,0 +1,43 @@
#include "Gameplay.hpp"
#include <iostream>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior character;
character.fixedUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double) {
const auto settings = game.fields(self, "tutorial.character");
const auto input = game.input();
auto velocity = game.velocity(self); // Metres per second; preserve the Y component.
velocity[0] = input.horizontal * settings.value("speed", 4.0f);
if (input.jumpPressed && game.grounded(self))
velocity[1] = settings.value("jump_speed", 5.0f);
// Do not multiply velocity by delta. The physics solver integrates it.
game.setVelocity(self, velocity);
};
character.onCollision = [](runtime::Runtime&, runtime::EntityHandle,
const runtime::CollisionEvent& event) {
if (event.began)
std::cout << "Character contact began\n";
};
world.registerBehavior("tutorial.character", std::move(character));
}
nlohmann::json schema() {
return nlohmann::json::array({{{"id", "tutorial.character"},
{"version", 1},
{"name", "Physics character"},
{"fields",
{{"speed",
{{"id", "speed"},
{"type", "number"},
{"default", 4.0},
{"min", 0.0},
{"units", "m/s"}}},
{"jump_speed",
{{"id", "jump_speed"},
{"type", "number"},
{"default", 5.0},
{"min", 0.0},
{"units", "m/s"}}}}}}});
}
} // namespace faset::gameplay
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+148
View File
@@ -0,0 +1,148 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-physics",
"name": "Physics tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "floor",
"name": "floor",
"parent": null,
"components": [
{
"id": "floor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
-0.5,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "floor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.4,
0.5,
0.6,
1
],
"size": [
12,
1
]
}
},
{
"id": "floor-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "static",
"half_extents": [
6,
0.5
]
}
}
]
},
{
"id": "actor",
"name": "actor",
"parent": null,
"components": [
{
"id": "actor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0.5,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "actor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.7,
0.9,
1
],
"size": [
1,
1
]
}
},
{
"id": "actor-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "dynamic",
"half_extents": [
0.5,
0.5
],
"density": 1,
"friction": 0.3,
"gravity_scale": 1
}
},
{
"id": "actor-tutorial.character",
"type": "tutorial.character",
"version": 1,
"fields": {
"speed": 4,
"jump_speed": 5
}
}
]
}
],
"instances": []
}
+70
View File
@@ -0,0 +1,70 @@
#include "Gameplay.hpp"
#include <map>
#include <tuple>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior spawner;
spawner.onStart = [](runtime::Runtime& game, runtime::EntityHandle self, double) {
const auto settings = game.fields(self, "tutorial.spawn_once");
const auto id = settings.at("spawned_id").get<std::string>();
// This queues a runtime object; it does not change the saved scene.
game.spawn(
{{"id", id},
{"name", "Temporary box"},
{"parent", nullptr},
{"components", nlohmann::json::array(
{{{"id", id + "/transform"},
{"type", "faset.transform"},
{"version", 1},
{"fields", {{"position", {0, 0, 0}}}}},
{{"id", id + "/sprite"},
{"type", "faset.sprite"},
{"version", 1},
{"fields", {{"color", {0.9, 0.6, 0.2, 1}}, {"size", {1, 1}}}}},
{{"id", id + "/lifetime"},
{"type", "tutorial.timed_despawn"},
{"version", 1},
{"fields", {{"seconds", settings.value("lifetime", 1.0)}}}}})}});
};
world.registerBehavior("tutorial.spawn_once", std::move(spawner));
// Ordinary C++ state belongs to the gameplay module. Every handle part matters.
using Key = std::tuple<std::uint64_t, std::uint32_t, std::uint64_t>;
auto ages = std::make_shared<std::map<Key, double>>();
auto key = [](runtime::EntityHandle h) { return Key{h.session, h.slot, h.generation}; };
runtime::Behavior lifetime;
lifetime.onStart = [ages, key](runtime::Runtime&, runtime::EntityHandle self, double) {
(*ages)[key(self)] = 0.0;
};
lifetime.fixedUpdate = [ages, key](runtime::Runtime& game, runtime::EntityHandle self,
double delta) {
auto& age = ages->at(key(self));
age += delta;
if (age >= game.fields(self, "tutorial.timed_despawn").value("seconds", 1.0))
game.destroy(self); // Still valid until the next fixed-tick barrier.
};
lifetime.onDestroy = [ages, key](runtime::Runtime&, runtime::EntityHandle self, double) {
ages->erase(key(self));
};
world.registerBehavior("tutorial.timed_despawn", std::move(lifetime));
}
nlohmann::json schema() {
return nlohmann::json::array(
{{{"id", "tutorial.spawn_once"},
{"version", 1},
{"name", "Spawn once"},
{"fields",
{{"spawned_id",
{{"id", "spawned_id"}, {"type", "string"}, {"default", "temporary-box"}}},
{"lifetime",
{{"id", "lifetime"}, {"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}}},
{{"id", "tutorial.timed_despawn"},
{"version", 1},
{"name", "Timed despawn"},
{"fields",
{{"seconds",
{{"id", "seconds"}, {"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}}}});
}
} // namespace faset::gameplay
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+58
View File
@@ -0,0 +1,58 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-spawning",
"name": "Spawning tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "spawner",
"name": "spawner",
"parent": null,
"components": [
{
"id": "spawner-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "spawner-tutorial.spawn_once",
"type": "tutorial.spawn_once",
"version": 1,
"fields": {
"spawned_id": "temporary-box",
"lifetime": 1.0
}
}
]
}
],
"instances": []
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace faset::assets {
using Json = nlohmann::json;
struct Vertex {
std::array<float, 3> position{};
std::array<float, 3> normal{0, 0, 1};
std::array<float, 2> uv{};
};
struct Primitive {
std::vector<Vertex> vertices;
std::vector<std::uint32_t> indices;
int material = -1;
};
struct Mesh {
std::string id, name;
std::vector<Primitive> primitives;
};
struct Node {
std::string id, name, parent_id;
int mesh = -1;
// glTF right-handed, Y-up, metres; column-major matrix.
std::array<float, 16> local_transform{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
bool stable_source_id = false;
};
struct Material {
std::string id, name;
std::array<float, 4> base_color{1, 1, 1, 1};
std::array<float, 3> emissive{};
float metallic = 1, roughness = 1, alpha_cutoff = 0.5f;
std::string alpha_mode = "OPAQUE";
bool double_sided = false, unlit = false;
int base_color_texture = -1, metallic_roughness_texture = -1;
int normal_texture = -1, occlusion_texture = -1, emissive_texture = -1;
};
struct Texture {
std::string id, name, mime_type;
// Encoded image bytes, owned by this value. Renderer selects an image decoder.
std::vector<std::byte> bytes;
int wrap_s = 10497, wrap_t = 10497, min_filter = 0, mag_filter = 0;
};
struct CookedAsset {
std::string asset_id, generation;
std::vector<Mesh> meshes;
std::vector<Node> nodes;
std::vector<Material> materials;
std::vector<Texture> textures;
};
// Read-only cooked storage. No source parser, import jobs or publication operations.
class AssetStore {
public:
explicit AssetStore(std::filesystem::path cache_root);
Json current_manifest(const std::string& asset_id) const;
CookedAsset load_asset(const std::string& asset_id) const;
std::filesystem::path generation_directory(const std::string& asset_id) const;
const std::filesystem::path& cache_root() const noexcept {
return cache_root_;
}
protected:
std::filesystem::path cache_root_;
};
} // namespace faset::assets
+14 -58
View File
@@ -1,4 +1,5 @@
#pragma once #pragma once
#include <faset/assets/asset_data.hpp>
#include <array> #include <array>
#include <atomic> #include <atomic>
@@ -7,69 +8,28 @@
#include <filesystem> #include <filesystem>
#include <functional> #include <functional>
#include <mutex> #include <mutex>
#include <nlohmann/json.hpp>
#include <string> #include <string>
#include <vector> #include <vector>
#include <nlohmann/json.hpp>
namespace faset::assets { namespace faset::assets {
using Json = nlohmann::json; using Json = nlohmann::json;
inline constexpr const char* importer_version = "faset-gltf-1/cgltf-1.15"; inline constexpr const char* importer_version = "faset-gltf-1/cgltf-1.15";
struct Vertex { struct ImportProgress {
std::array<float, 3> position{}; float fraction = 0;
std::array<float, 3> normal{0, 0, 1}; std::string stage;
std::array<float, 2> uv{};
}; };
struct Primitive {
std::vector<Vertex> vertices;
std::vector<std::uint32_t> indices;
int material = -1;
};
struct Mesh {
std::string id, name;
std::vector<Primitive> primitives;
};
struct Node {
std::string id, name, parent_id;
int mesh = -1;
// glTF right-handed, Y-up, metres; column-major matrix.
std::array<float, 16> local_transform{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
bool stable_source_id = false;
};
struct Material {
std::string id, name;
std::array<float, 4> base_color{1,1,1,1};
std::array<float, 3> emissive{};
float metallic = 1, roughness = 1, alpha_cutoff = 0.5f;
std::string alpha_mode = "OPAQUE";
bool double_sided = false, unlit = false;
int base_color_texture = -1, metallic_roughness_texture = -1;
int normal_texture = -1, occlusion_texture = -1, emissive_texture = -1;
};
struct Texture {
std::string id, name, mime_type;
// Encoded image bytes, owned by this value. Renderer selects an image decoder.
std::vector<std::byte> bytes;
int wrap_s = 10497, wrap_t = 10497, min_filter = 0, mag_filter = 0;
};
struct CookedAsset {
std::string asset_id, generation;
std::vector<Mesh> meshes;
std::vector<Node> nodes;
std::vector<Material> materials;
std::vector<Texture> textures;
};
struct ImportProgress { float fraction = 0; std::string stage; };
class ImportJob { class ImportJob {
public: public:
using Observer = std::function<void(const ImportProgress&)>; using Observer = std::function<void(const ImportProgress&)>;
explicit ImportJob(Observer observer = {}); explicit ImportJob(Observer observer = {});
void cancel() noexcept; void cancel() noexcept;
bool cancelled() const noexcept; bool cancelled() const noexcept;
ImportProgress progress() const; ImportProgress progress() const;
void report(float fraction, std::string stage); void report(float fraction, std::string stage);
private:
private:
std::atomic<bool> cancelled_{false}; std::atomic<bool> cancelled_{false};
mutable std::mutex mutex_; mutable std::mutex mutex_;
ImportProgress progress_; ImportProgress progress_;
@@ -79,7 +39,7 @@ private:
enum class ImportStatus { succeeded, failed, cancelled, conflict }; enum class ImportStatus { succeeded, failed, cancelled, conflict };
struct ImportRequest { struct ImportRequest {
std::filesystem::path source; std::filesystem::path source;
std::string asset_id{}; // Empty: restore/create source.faset-import.json identity. std::string asset_id{}; // Empty: restore/create source.faset-import.json identity.
Json settings = nullptr; // Null restores the sidecar recipe; an object replaces it. Json settings = nullptr; // Null restores the sidecar recipe; an object replaces it.
// Explicit conflict resolution; false keeps the previous generation active. // Explicit conflict resolution; false keeps the previous generation active.
bool allow_removed_outputs = false; bool allow_removed_outputs = false;
@@ -91,24 +51,20 @@ struct ImportResult {
std::vector<std::string> removed_output_ids; std::vector<std::string> removed_output_ids;
Json manifest; Json manifest;
bool cache_hit = false; bool cache_hit = false;
bool ok() const noexcept { return status == ImportStatus::succeeded; } bool ok() const noexcept {
return status == ImportStatus::succeeded;
}
}; };
// A pipeline is an authoring service. Player only needs read-only cooked data. // A pipeline is an authoring service. Player only needs read-only cooked data.
// Writers in one process serialize publication; a cache root has one service owner. // Writers in one process serialize publication; a cache root has one service owner.
class AssetPipeline { class AssetPipeline : public AssetStore {
public: public:
explicit AssetPipeline(std::filesystem::path cache_root); explicit AssetPipeline(std::filesystem::path cache_root);
ImportResult import_asset(const ImportRequest& request, ImportJob& job); ImportResult import_asset(const ImportRequest& request, ImportJob& job);
ImportResult import_asset(const ImportRequest& request); ImportResult import_asset(const ImportRequest& request);
Json current_manifest(const std::string& asset_id) const;
CookedAsset load_asset(const std::string& asset_id) const;
// Overrides are authoring data beside the source, never generated cache contents. // Overrides are authoring data beside the source, never generated cache contents.
Json overrides(const std::string& asset_id) const; Json overrides(const std::string& asset_id) const;
void set_overrides(const std::string& asset_id, const Json& overrides); void set_overrides(const std::string& asset_id, const Json& overrides);
std::filesystem::path generation_directory(const std::string& asset_id) const;
const std::filesystem::path& cache_root() const noexcept { return cache_root_; }
private:
std::filesystem::path cache_root_;
}; };
} // namespace faset::assets } // namespace faset::assets
+33 -19
View File
@@ -1,13 +1,13 @@
#pragma once #pragma once
#include <faset/core/json.hpp>
#include <faset/core/error.hpp> #include <faset/core/error.hpp>
#include <faset/core/json.hpp>
#include <map> #include <map>
#include <string> #include <string>
#include <type_traits> #include <type_traits>
namespace faset::authoring { namespace faset::authoring {
class SchemaRegistry { class SchemaRegistry {
public: public:
void register_schema(const Json& schema); void register_schema(const Json& schema);
void register_schemas(const Json& schemas); void register_schemas(const Json& schemas);
bool contains(const std::string& type) const; bool contains(const std::string& type) const;
@@ -18,29 +18,43 @@ public:
void validate_component(const Json& component) const; void validate_component(const Json& component) const;
Json migrate_component(const Json& component) const; Json migrate_component(const Json& component) const;
void add_migration(const std::string& type, int from_version, Json field_rules); void add_migration(const std::string& type, int from_version, Json field_rules);
private:
std::map<std::string,Json> schemas_; private:
std::map<std::pair<std::string,int>,Json> migrations_; std::map<std::string, Json> schemas_;
std::map<std::pair<std::string, int>, Json> migrations_;
}; };
template<class T> class TypeRegistration { template <class T> class TypeRegistration {
public: public:
TypeRegistration(SchemaRegistry& registry, std::string id, std::string name, int version=1) TypeRegistration(SchemaRegistry& registry, std::string id, std::string name, int version = 1)
: registry_(registry),schema_{{"id",std::move(id)},{"name",std::move(name)},{"version",version},{"fields",Json::object()}} {} : registry_(registry), schema_{{"id", std::move(id)},
template<class Value> {"name", std::move(name)},
TypeRegistration& field(std::string id, std::string name, Value T::*member, Value default_value, {"version", version},
std::string kind, Json constraints=Json::object()) { {"fields", Json::object()}} {}
template <class Value>
TypeRegistration& field(std::string id, std::string name, Value T::* member,
Value default_value, std::string kind,
Json constraints = Json::object()) {
static_assert(std::is_member_object_pointer_v<decltype(member)>); static_assert(std::is_member_object_pointer_v<decltype(member)>);
require(!schema_["fields"].contains(id),"schema.duplicate_field","Duplicate stable FieldId"); require(!schema_["fields"].contains(id), "schema.duplicate_field",
"Duplicate stable FieldId");
// Converting the typed default checks supported JSON serialization at compile time. // Converting the typed default checks supported JSON serialization at compile time.
Json descriptor={{"id",id},{"name",std::move(name)},{"type",std::move(kind)},{"default",Json(default_value)}}; Json descriptor = {{"id", id},
descriptor.update(constraints); schema_["fields"][id]=std::move(descriptor); return *this; {"name", std::move(name)},
{"type", std::move(kind)},
{"default", Json(default_value)}};
descriptor.update(constraints);
schema_["fields"][id] = std::move(descriptor);
return *this;
} }
void commit() { registry_.register_schema(schema_); } void commit() {
private: registry_.register_schema(schema_);
}
private:
SchemaRegistry& registry_; SchemaRegistry& registry_;
Json schema_; Json schema_;
}; };
SchemaRegistry builtin_schemas(); SchemaRegistry builtin_schemas();
void validate_field(const Json& value,const Json& descriptor); void validate_field(const Json& value, const Json& descriptor);
} } // namespace faset::authoring
+34 -23
View File
@@ -3,47 +3,58 @@
#include <filesystem> #include <filesystem>
#include <map> #include <map>
#include <mutex> #include <mutex>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
namespace faset::authoring { namespace faset::authoring {
Json make_scene(std::string name,int dimension=3); Json make_scene(std::string name, int dimension = 3);
Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent=""); Json make_entity(const SchemaRegistry& schemas, std::string name, const std::string& parent = "");
void validate_scene(const Json& scene,const SchemaRegistry& schemas); void validate_scene(const Json& scene, const SchemaRegistry& schemas);
class AuthoringService { class AuthoringService {
public: public:
explicit AuthoringService(std::filesystem::path project_root,SchemaRegistry schemas=builtin_schemas()); explicit AuthoringService(std::filesystem::path project_root,
Json create(std::string name,int dimension=3); SchemaRegistry schemas = builtin_schemas());
Json open(const std::filesystem::path& relative,bool recover=false); Json create(std::string name, int dimension = 3);
Json open(const std::filesystem::path& relative, bool recover = false);
Json query(const std::string& document) const; Json query(const std::string& document) const;
Json documents() const; Json documents() const;
Json transact(const std::string& document,std::uint64_t expected_revision,const Json& operations,const std::string& idempotency_key=""); Json transact(const std::string& document, std::uint64_t expected_revision,
Json undo(const std::string& document,std::uint64_t expected_revision); const Json& operations, const std::string& idempotency_key = "");
Json redo(const std::string& document,std::uint64_t expected_revision); Json undo(const std::string& document, std::uint64_t expected_revision);
Json save(const std::string& document,const std::filesystem::path& relative={}); Json redo(const std::string& document, std::uint64_t expected_revision);
Json save(const std::string& document, const std::filesystem::path& relative = {});
Json recovery_documents() const; Json recovery_documents() const;
const SchemaRegistry& schemas() const {return schemas_;} Json recover(const std::string& document,
std::optional<std::uint64_t> expected_revision = std::nullopt);
const SchemaRegistry& schemas() const {
return schemas_;
}
void register_schemas(const Json& manifest); void register_schemas(const Json& manifest);
const std::filesystem::path& root() const {return root_;} void replace_external_schemas(const Json& manifest);
private: const std::filesystem::path& root() const {
return root_;
}
private:
struct State { struct State {
Json data; Json data;
std::uint64_t revision=0; std::uint64_t revision = 0;
std::filesystem::path path; std::filesystem::path path;
std::string saved_hash,disk_hash; std::string saved_hash, disk_hash;
std::vector<Json> undo,redo; std::vector<Json> undo, redo;
std::map<std::string,std::pair<std::string,Json>> requests; std::map<std::string, std::pair<std::string, Json>> requests;
}; };
Json summary(const State& state,bool include_data=true) const; Json summary(const State& state, bool include_data = true) const;
State& state(const std::string& document); State& state(const std::string& document);
const State& state(const std::string& document) const; const State& state(const std::string& document) const;
void journal(const State& state) const; void journal(const State& state) const;
void apply(Json& scene,const Json& operation); void apply(Json& scene, const Json& operation);
Json history(const std::string& document,std::uint64_t revision,bool redo); Json history(const std::string& document, std::uint64_t revision, bool redo);
std::filesystem::path root_; std::filesystem::path root_;
SchemaRegistry schemas_; SchemaRegistry schemas_;
std::map<std::string,State> documents_; std::map<std::string, State> documents_;
mutable std::recursive_mutex mutex_; mutable std::recursive_mutex mutex_;
}; };
} } // namespace faset::authoring
+8 -4
View File
@@ -4,8 +4,12 @@
#include <string> #include <string>
namespace faset::authoring { namespace faset::authoring {
struct ResolvedScene {Json scene;Json conflicts=Json::array();}; struct ResolvedScene {
using SceneLoader=std::function<Json(const std::string&)>; Json scene;
Json conflicts = Json::array();
};
using SceneLoader = std::function<Json(const std::string&)>;
// Source documents are immutable inputs. Conflicting records stay in the authoring file. // Source documents are immutable inputs. Conflicting records stay in the authoring file.
ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader); ResolvedScene resolve_templates(const Json& scene, const SchemaRegistry& schemas,
} const SceneLoader& loader);
} // namespace faset::authoring
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <faset/core/json.hpp>
#include <string>
namespace faset::authoring {
// Reparents authored TRS while optionally preserving the complete world transform.
// Non-invertible parents and transforms requiring shear are rejected atomically.
void reparent_entity(Json& scene, const std::string& entity, const Json& parent, bool keep_world);
} // namespace faset::authoring
+14 -7
View File
@@ -5,16 +5,23 @@
namespace faset { namespace faset {
class Error : public std::runtime_error { class Error : public std::runtime_error {
public: public:
Error(std::string code, std::string message, Json details = Json::object()) Error(std::string code, std::string message, Json details = Json::object())
: std::runtime_error(std::move(message)), code_(std::move(code)), details_(std::move(details)) {} : std::runtime_error(std::move(message)), code_(std::move(code)),
const std::string& code() const noexcept { return code_; } details_(std::move(details)) {}
Json json() const { return {{"code", code_}, {"message", what()}, {"details", details_}}; } const std::string& code() const noexcept {
private: return code_;
}
Json json() const {
return {{"code", code_}, {"message", what()}, {"details", details_}};
}
private:
std::string code_; std::string code_;
Json details_; Json details_;
}; };
inline void require(bool condition, const std::string& code, const std::string& message) { inline void require(bool condition, const std::string& code, const std::string& message) {
if (!condition) throw Error(code, message); if (!condition)
} throw Error(code, message);
} }
} // namespace faset
+1 -1
View File
@@ -11,4 +11,4 @@ inline std::string sha256(std::string_view text) {
return sha256(std::as_bytes(std::span(text.data(), text.size()))); return sha256(std::as_bytes(std::span(text.data(), text.size())));
} }
std::string sha256_file(const std::filesystem::path& path); std::string sha256_file(const std::filesystem::path& path);
} } // namespace faset
+3 -2
View File
@@ -11,5 +11,6 @@ Json read_json(const std::filesystem::path& path);
void atomic_write(const std::filesystem::path& path, std::string_view bytes); void atomic_write(const std::filesystem::path& path, std::string_view bytes);
void atomic_write_json(const std::filesystem::path& path, const Json& value); void atomic_write_json(const std::filesystem::path& path, const Json& value);
// Rejects traversal and symlink escapes before project-scoped file operations. // Rejects traversal and symlink escapes before project-scoped file operations.
std::filesystem::path project_path(const std::filesystem::path& root, const std::filesystem::path& relative); std::filesystem::path project_path(const std::filesystem::path& root,
} const std::filesystem::path& relative);
} // namespace faset
+3 -1
View File
@@ -1,3 +1,5 @@
#pragma once #pragma once
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
namespace faset { using Json = nlohmann::json; } namespace faset {
using Json = nlohmann::json;
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <filesystem>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <vector>
namespace faset {
struct ProcessOptions {
// First element is the executable. Arguments are passed directly, never through a shell.
std::vector<std::string> arguments;
std::filesystem::path working_directory;
std::map<std::string, std::string> environment;
};
struct ProcessPoll {
bool running{};
std::optional<int> exit_code;
std::string output; // Newly available stdout and stderr, combined.
};
class Process {
public:
explicit Process(const ProcessOptions&);
~Process();
Process(Process&&) noexcept;
Process& operator=(Process&&) noexcept;
Process(const Process&) = delete;
Process& operator=(const Process&) = delete;
ProcessPoll poll();
// Terminates the process group/job, including its compiler children.
void cancel();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
std::filesystem::path find_executable(const std::string& name);
} // namespace faset
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <faset/core/json.hpp>
#include <filesystem>
#include <memory>
#include <string>
#include <vector>
namespace faset::editor {
struct BuildConfig {
std::filesystem::path project_root;
std::filesystem::path engine_root;
std::filesystem::path build_directory;
std::filesystem::path cache_root;
// Separate CMake caches prevent an export from changing the active development build.
std::string configuration{"Debug"};
std::string export_configuration{"Release"};
std::string cmake{"cmake"};
std::string generator{"Ninja"};
std::vector<std::string> configure_arguments;
};
struct JobStatus {
std::string id, kind, state{"queued"}, stage{"queued"};
double progress{};
std::string log, error;
Json result = Json::object();
bool finished() const {
return state == "succeeded" || state == "failed" || state == "cancelled";
}
Json json() const;
};
// One serialized build/cook worker per project. GUI and MCP use the same service.
// Input scenes must be resolved authoring snapshots, independent of live runtime state.
class BuildService {
public:
explicit BuildService(BuildConfig);
~BuildService();
BuildService(const BuildService&) = delete;
BuildService& operator=(const BuildService&) = delete;
void scaffold(const std::string& name, int dimension);
std::string start_build();
std::string start_cook(Json resolved_scene);
// Publishes output/generations/<id>; current.json changes only after all validation succeeds.
std::string start_export(Json resolved_scene, const std::filesystem::path& output_directory);
JobStatus job(const std::string& id) const;
std::vector<JobStatus> jobs() const;
void cancel(const std::string& id);
JobStatus wait(const std::string& id);
const BuildConfig& config() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
void write_cooked_scene(const std::filesystem::path& path, const Json& resolved_scene);
} // namespace faset::editor
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <faset/authoring/service.hpp>
#include <functional>
#include <map>
#include <string>
namespace faset::editor {
class Commands {
public:
using Handler = std::function<Json(const Json&)>;
explicit Commands(authoring::AuthoringService& authoring);
void add(std::string name, std::string description, Json input_schema, Handler handler,
bool read_only = false);
Json list() const;
void remove(const std::string& name);
Json call(const std::string& name, const Json& arguments);
Json resolved_scene(const std::string& document) const;
authoring::AuthoringService& authoring() {
return authoring_;
}
static Json object_schema(Json properties, Json required = Json::array());
private:
struct Command {
Json descriptor;
Handler handler;
};
authoring::AuthoringService& authoring_;
std::map<std::string, Command> commands_;
};
} // namespace faset::editor
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <faset/editor/session.hpp>
#include <faset/ui/ui.hpp>
#include <memory>
namespace faset::editor {
// Owns authoring presentation only. Player execution and MCP transport stay in
// the application.
class EditorUI {
public:
EditorUI(Session&, render::Renderer&, const std::filesystem::path& font,
const std::filesystem::path& styles);
~EditorUI();
EditorUI(const EditorUI&) = delete;
EditorUI& operator=(const EditorUI&) = delete;
void frame(const std::vector<render::Event>&);
const render::Snapshot& snapshot() const;
ui::Context& widgets();
const std::string& current_document() const;
void select_document(const std::string& document);
const std::string& selected_entity() const;
void select_entity(const std::string& entity);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace faset::editor
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <faset/editor/commands.hpp>
#include <optional>
#include <string>
#include <vector>
namespace faset::editor {
// MCP is compiled exclusively into the Editor / headless authoring executable.
class McpServer {
public:
explicit McpServer(Commands& commands) : commands_(commands) {}
std::optional<Json> handle(const Json& message);
Json parse_error() const;
private:
Commands& commands_;
bool initialized_ = false, ready_ = false;
};
class StdioTransport {
public:
// Nonblocking: GUI and MCP can share the same authoring session and event loop.
std::vector<std::string> poll();
bool closed() const noexcept {
return closed_;
}
void send(const Json& message);
private:
std::string buffer_;
bool closed_ = false;
};
} // namespace faset::editor
+42
View File
@@ -0,0 +1,42 @@
#pragma once
/* Exact-build native Editor SDK. No STL types or ownership cross this ABI. */
#include <faset/editor/sdk_build.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define FASET_EDITOR_API_VERSION 1u
#if defined(_WIN32)
#define FASET_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FASET_PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
/* UTF-8 JSON is borrowed for the duration of a call. write() copies output in
the receiving module. Return zero on success; errors use {code,message}. */
typedef void (*FasetWrite)(void* receiver, const char* utf8, uint64_t length);
typedef int (*FasetCommand)(void* user, const char* arguments_json, FasetWrite write,
void* receiver);
typedef struct FasetEditorHost {
uint32_t api_version;
uint32_t struct_size;
const char* build_fingerprint;
void* context;
int (*register_command)(void* context, const char* descriptor_json, FasetCommand callback,
void* user);
int (*register_panel)(void* context, const char* panel_json);
int (*invoke_command)(void* context, const char* name, const char* arguments_json,
FasetWrite write, void* receiver);
void (*log)(void* context, const char* utf8);
} FasetEditorHost;
typedef struct FasetEditorPlugin {
uint32_t api_version;
uint32_t struct_size;
const char* build_fingerprint;
void* user;
void (*shutdown)(void* user);
} FasetEditorPlugin;
/* Required exported symbol. Called once at startup, after dependencies load. */
typedef int (*FasetPluginEntry)(const FasetEditorHost* host, FasetEditorPlugin* plugin);
#ifdef __cplusplus
}
#endif
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <faset/editor/commands.hpp>
#include <functional>
#include <memory>
namespace faset::editor {
class PluginManager {
public:
using Logger = std::function<void(std::string)>;
explicit PluginManager(Commands&, Logger);
~PluginManager();
PluginManager(const PluginManager&) = delete;
PluginManager& operator=(const PluginManager&) = delete;
// Discover and validate the complete dependency graph before loading code.
// Individual failures are reported; dependents are never loaded after one.
void load(const std::filesystem::path& directory);
Json status() const;
Json panels() const;
static std::string fingerprint();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace faset::editor
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/editor/commands.hpp>
#include <faset/editor/plugins.hpp>
#include <memory>
#include <mutex>
#include <thread>
namespace faset::editor {
struct SessionConfig {
std::filesystem::path project_root, engine_root, binary_directory;
};
class Session {
public:
explicit Session(SessionConfig);
~Session();
Session(const Session&) = delete;
Session& operator=(const Session&) = delete;
authoring::AuthoringService& authoring() {
return authoring_;
}
Commands& commands() {
return commands_;
}
void poll();
const std::vector<std::string>& logs() const {
return logs_;
}
Json project() const;
Json plugin_panels() const {
return plugins_ ? plugins_->panels() : Json::array();
}
void scaffold(const std::string& name, int dimension);
const SessionConfig& config() const {
return config_;
}
bool playing() const {
return bool(player_);
}
void log(std::string message);
private:
struct ImportTask;
void register_commands();
Json assets_list() const;
Json jobs() const;
Json job(const std::string& id) const;
void load_schema(const std::filesystem::path& path);
void launch_player(Json scene, const std::filesystem::path& executable);
void stop_player();
SessionConfig config_;
authoring::AuthoringService authoring_;
Commands commands_;
assets::AssetPipeline assets_;
BuildService builds_;
std::map<std::string, std::shared_ptr<ImportTask>> imports_;
std::vector<std::jthread> workers_;
std::vector<std::string> logs_;
std::map<std::string, std::string> observed_jobs_;
std::unique_ptr<Process> player_;
std::filesystem::path control_path_;
std::uint64_t control_sequence_ = 0;
std::string pending_play_job_;
Json pending_play_scene_;
std::unique_ptr<PluginManager> plugins_;
};
} // namespace faset::editor
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <faset/render/renderer.hpp>
#include <filesystem>
#include <memory>
#include <nlohmann/json.hpp>
namespace faset::player {
struct CameraSettings {
// Defaults frame the small demo scenes. An explicit editor camera overrides
// scene camera components without changing the authoring document.
bool overrideSceneCamera{false};
render::Vec3 eye{8, 6, 10};
render::Vec3 target{0, 0, 0};
float verticalFovDegrees{60};
float orthographicHeight{12};
float nearPlane{0.1f}, farPlane{1000};
};
class SceneView {
public:
// cacheRoot contains assets/<AssetId>/current.json and generation folders.
explicit SceneView(std::filesystem::path cacheRoot);
~SceneView();
SceneView(const SceneView&) = delete;
SceneView& operator=(const SceneView&) = delete;
render::Snapshot build(const nlohmann::json& flatSceneOrRuntimeSnapshot, float aspect,
CameraSettings camera = {});
void clearCache();
const std::vector<std::string>& diagnostics() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// Reads JSON development scenes or a strict FASETSCN v1 CBOR envelope.
nlohmann::json readScene(const std::filesystem::path& path);
} // namespace faset::player
+11 -5
View File
@@ -6,15 +6,21 @@ namespace faset::render {
// Ordered single-queue graph. Reads must be imported or produced by an earlier pass. // Ordered single-queue graph. Reads must be imported or produced by an earlier pass.
// The Vulkan executor performs barriers at each resource state transition. // The Vulkan executor performs barriers at each resource state transition.
class RenderGraph { class RenderGraph {
public: public:
using Callback = std::function<void()>; using Callback = std::function<void()>;
void import(std::string resource); void import(std::string resource);
void add(std::string name, std::vector<std::string> reads, std::vector<std::string> writes, Callback execute); void add(std::string name, std::vector<std::string> reads, std::vector<std::string> writes,
Callback execute);
void execute() const; void execute() const;
std::vector<std::string> pass_names() const; std::vector<std::string> pass_names() const;
private:
struct Pass {std::string name; std::vector<std::string> reads, writes; Callback callback;}; private:
struct Pass {
std::string name;
std::vector<std::string> reads, writes;
Callback callback;
};
std::vector<std::string> imports_; std::vector<std::string> imports_;
std::vector<Pass> passes_; std::vector<Pass> passes_;
}; };
} } // namespace faset::render
+65 -20
View File
@@ -11,37 +11,68 @@ using Vec2 = std::array<float, 2>;
using Vec3 = std::array<float, 3>; using Vec3 = std::array<float, 3>;
using Color = std::array<float, 4>; using Color = std::array<float, 4>;
using Mat4 = std::array<float, 16>; using Mat4 = std::array<float, 16>;
inline constexpr Mat4 identity{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; inline constexpr Mat4 identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
// Matrices are column-major, vectors are columns; clip depth is Vulkan's [0,1]. // Matrices are column-major, vectors are columns; clip depth is Vulkan's [0,1].
Mat4 multiply(const Mat4&, const Mat4&); Mat4 multiply(const Mat4&, const Mat4&);
Mat4 transform(Vec3 position, Vec3 rotation = {}, Vec3 scale = {1,1,1}); Mat4 transform(Vec3 position, Vec3 rotation = {}, Vec3 scale = {1, 1, 1});
Mat4 perspective(float vertical_fov_radians, float aspect, float near_plane, float far_plane); Mat4 perspective(float vertical_fov_radians, float aspect, float near_plane, float far_plane);
Mat4 orthographic(float left, float right, float bottom, float top, float near_plane, float far_plane); Mat4 orthographic(float left, float right, float bottom, float top, float near_plane,
Mat4 look_at(Vec3 eye, Vec3 target, Vec3 up = {0,1,0}); float far_plane);
struct Vertex { Vec3 position{}; Vec3 normal{0,0,1}; Color color{1,1,1,1}; Vec2 uv{}; }; Mat4 look_at(Vec3 eye, Vec3 target, Vec3 up = {0, 1, 0});
struct Mesh { std::vector<Vertex> vertices; std::vector<std::uint32_t> indices; }; struct Vertex {
Vec3 position{};
Vec3 normal{0, 0, 1};
Color color{1, 1, 1, 1};
Vec2 uv{};
};
struct Mesh {
std::vector<Vertex> vertices;
std::vector<std::uint32_t> indices;
};
std::shared_ptr<const Mesh> cube_mesh(); std::shared_ptr<const Mesh> cube_mesh();
struct Texture; struct Texture;
struct DrawItem { struct DrawItem {
std::shared_ptr<const Mesh> mesh; std::shared_ptr<const Mesh> mesh;
Mat4 model{identity}; Mat4 model{identity};
Color color{1,1,1,1}; Color color{1, 1, 1, 1};
float roughness{0.65f}; float roughness{0.65f};
float metallic{0.0f}; float metallic{0.0f};
bool cast_shadow{true}; bool cast_shadow{true};
std::shared_ptr<const Texture> texture; std::shared_ptr<const Texture> texture;
}; };
struct Sprite { Vec3 position{}; Vec2 size{1,1}; Color color{1,1,1,1}; float rotation{}; std::shared_ptr<const Texture> texture; }; struct Sprite {
struct Texture { std::uint32_t width{}, height{}; std::vector<std::uint8_t> rgba; std::uint64_t revision{}; bool srgb{false}; }; Vec3 position{};
struct Quad { float x{}, y{}, width{}, height{}; Color color{1,1,1,1}; std::shared_ptr<const Texture> texture; std::array<float,4> uv_rect{0,0,1,1}; }; Vec2 size{1, 1};
struct Text { float x{}, y{}; std::string value; Color color{0.85f,0.87f,0.90f,1}; float size{14}; }; Color color{1, 1, 1, 1};
float rotation{};
std::shared_ptr<const Texture> texture;
};
struct Texture {
std::uint32_t width{}, height{};
std::vector<std::uint8_t> rgba;
std::uint64_t revision{};
bool srgb{false};
};
struct Quad {
float x{}, y{}, width{}, height{};
Color color{1, 1, 1, 1};
std::shared_ptr<const Texture> texture;
std::array<float, 4> uv_rect{0, 0, 1, 1};
};
struct Text {
float x{}, y{};
std::string value;
Color color{0.85f, 0.87f, 0.90f, 1};
float size{14};
};
struct Snapshot { struct Snapshot {
// Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full target. // Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full
std::array<float,4> scene_rect{}; // target.
std::array<float, 4> scene_rect{};
Mat4 view_projection{identity}; Mat4 view_projection{identity};
Vec3 eye{4,3,5}; Vec3 eye{4, 3, 5};
Vec3 light_direction{-0.5f,-1,-0.3f}; Vec3 light_direction{-0.5f, -1, -0.3f};
Color clear_color{0.055f,0.065f,0.085f,1}; Color clear_color{0.055f, 0.065f, 0.085f, 1};
std::vector<DrawItem> draws; std::vector<DrawItem> draws;
std::vector<Sprite> sprites; std::vector<Sprite> sprites;
// UI coordinates are drawable pixels, top-left origin. Order is preserved per list. // UI coordinates are drawable pixels, top-left origin. Order is preserved per list.
@@ -55,7 +86,20 @@ struct RendererConfig {
bool validation{true}; bool validation{true};
}; };
struct Event { struct Event {
enum class Type { Quit, Resize, FocusGained, FocusLost, MouseMove, MouseDown, MouseUp, Wheel, KeyDown, KeyUp, TextInput, TextEditing }; enum class Type {
Quit,
Resize,
FocusGained,
FocusLost,
MouseMove,
MouseDown,
MouseUp,
Wheel,
KeyDown,
KeyUp,
TextInput,
TextEditing
};
Type type{}; Type type{};
float x{}, y{}; float x{}, y{};
int button{}; int button{};
@@ -71,7 +115,7 @@ struct FrameStats {
std::string device; std::string device;
}; };
class Renderer { class Renderer {
public: public:
explicit Renderer(const RendererConfig& = {}); explicit Renderer(const RendererConfig& = {});
~Renderer(); ~Renderer();
Renderer(Renderer&&) noexcept; Renderer(Renderer&&) noexcept;
@@ -95,8 +139,9 @@ public:
void set_text_input_area(float x, float y, float width, float height); void set_text_input_area(float x, float y, float width, float height);
void set_clipboard(const std::string&); void set_clipboard(const std::string&);
std::string clipboard() const; std::string clipboard() const;
private:
private:
struct Impl; struct Impl;
std::unique_ptr<Impl> impl_; std::unique_ptr<Impl> impl_;
}; };
} } // namespace faset::render
+20 -6
View File
@@ -4,10 +4,10 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <nlohmann/json.hpp>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
#include <nlohmann/json.hpp>
namespace faset::runtime { namespace faset::runtime {
@@ -26,7 +26,9 @@ struct EntityHandle {
std::uint64_t session{}; std::uint64_t session{};
std::uint32_t slot{}; std::uint32_t slot{};
std::uint64_t generation{}; std::uint64_t generation{};
explicit operator bool() const noexcept { return session != 0; } explicit operator bool() const noexcept {
return session != 0;
}
bool operator==(const EntityHandle&) const = default; bool operator==(const EntityHandle&) const = default;
}; };
@@ -37,8 +39,17 @@ struct InputState {
bool interactPressed{}; bool interactPressed{};
}; };
struct Sprite { Vec4 color{1, 1, 1, 1}; Vec2 size{1, 1}; std::string texture; int layer{}; }; struct Sprite {
struct Mesh { std::string asset; Vec4 color{1, 1, 1, 1}; std::string primitive{"cube"}; }; Vec4 color{1, 1, 1, 1};
Vec2 size{1, 1};
std::string texture;
int layer{};
};
struct Mesh {
std::string asset;
Vec4 color{1, 1, 1, 1};
std::string primitive{"cube"};
};
struct RenderEntity { struct RenderEntity {
std::string id; std::string id;
std::string name; std::string name;
@@ -86,7 +97,7 @@ struct CollisionEvent {
// Single-owner sequential runtime. Gameplay callbacks run on the caller's thread. // Single-owner sequential runtime. Gameplay callbacks run on the caller's thread.
// No Editor, MCP, renderer or platform service is linked by this API. // No Editor, MCP, renderer or platform service is linked by this API.
class Runtime { class Runtime {
public: public:
explicit Runtime(RuntimeConfig config = {}); explicit Runtime(RuntimeConfig config = {});
~Runtime(); ~Runtime();
Runtime(const Runtime&) = delete; Runtime(const Runtime&) = delete;
@@ -111,6 +122,9 @@ public:
// Configuration copy. Live poses and velocities have their own typed accessors. // Configuration copy. Live poses and velocities have their own typed accessors.
nlohmann::json fields(EntityHandle handle, const std::string& componentType) const; nlohmann::json fields(EntityHandle handle, const std::string& componentType) const;
Vec3 velocity(EntityHandle handle) const; Vec3 velocity(EntityHandle handle) const;
// Support from the last completed physics step. Checks actual contact normals
// against opposite gravity (Y-up if gravity is zero), not vertical speed.
bool grounded(EntityHandle handle) const;
InputState input() const noexcept; InputState input() const noexcept;
// Valid until the next fixed tick or scene replacement. No native solver pointers. // Valid until the next fixed tick or scene replacement. No native solver pointers.
const std::vector<CollisionEvent>& collisions() const noexcept; const std::vector<CollisionEvent>& collisions() const noexcept;
@@ -135,7 +149,7 @@ public:
std::uint64_t session() const noexcept; std::uint64_t session() const noexcept;
const std::vector<std::string>& diagnostics() const noexcept; const std::vector<std::string>& diagnostics() const noexcept;
private: private:
struct Impl; struct Impl;
std::unique_ptr<Impl> impl_; std::unique_ptr<Impl> impl_;
}; };
+183
View File
@@ -0,0 +1,183 @@
#pragma once
#include <faset/render/renderer.hpp>
#include <filesystem>
#include <functional>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace faset::ui {
using Json = nlohmann::json;
using Color = render::Color;
struct Rect {
float x{}, y{}, width{}, height{};
bool contains(float px, float py) const noexcept;
Rect intersection(const Rect&) const noexcept;
};
struct Theme {
Color background{.075f, .078f, .086f, 1}, surface{.10f, .105f, .115f, 1};
Color raised{.135f, .14f, .15f, 1}, hover{.175f, .18f, .20f, 1}, border{.23f, .235f, .255f, 1};
Color text{.86f, .875f, .90f, 1}, muted{.55f, .575f, .62f, 1}, accent{.65f, .60f, .88f, 1};
Color selection{.26f, .245f, .35f, 1}, danger{.92f, .39f, .38f, 1};
float font_size = 14, row_height = 28, padding = 8, gap = 4;
static Theme from_json(const Json&);
static Theme load(const std::filesystem::path&);
Json to_json() const;
};
// Byte offsets always remain valid UTF-8 boundaries. Undo is local to an
// unfinished edit.
class TextBuffer {
public:
explicit TextBuffer(std::string text = {});
const std::string& text() const noexcept {
return text_;
}
std::size_t cursor() const noexcept {
return cursor_;
}
std::size_t anchor() const noexcept {
return anchor_;
}
bool has_selection() const noexcept {
return cursor_ != anchor_;
}
std::string selected_text() const;
void reset(std::string text);
void set_cursor(std::size_t byte_offset, bool select = false);
void select_all();
void left(bool select = false, bool by_word = false);
void right(bool select = false, bool by_word = false);
void home(bool select = false);
void end(bool select = false);
bool insert(std::string_view utf8);
bool backspace();
bool delete_forward();
bool undo();
bool redo();
static bool valid_utf8(std::string_view);
private:
struct State {
std::string text;
std::size_t cursor, anchor;
};
std::string text_;
std::size_t cursor_{}, anchor_{};
std::vector<State> undo_, redo_;
void remember();
void erase_selection();
};
class FontAtlas {
public:
explicit FontAtlas(const std::filesystem::path& font);
~FontAtlas();
FontAtlas(const FontAtlas&) = delete;
FontAtlas& operator=(const FontAtlas&) = delete;
float measure(std::string_view utf8, float pixels);
// y is the top of the line, not the baseline; clipping also adjusts glyph
// UVs.
void draw(render::Snapshot&, std::string_view utf8, float x, float y, float pixels, Color,
const Rect& clip);
std::shared_ptr<const render::Texture> texture() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
enum class Kind {
Panel,
Row,
Column,
Label,
Button,
Tab,
TreeRow,
TextField,
NumberField,
Checkbox,
Divider,
Viewport
};
struct Layout {
float width = -1, height = -1, flex = 0;
float min_width = 0, min_height = 0, max_width = 100000, max_height = 100000;
float padding = 0, gap = 4;
bool absolute = false, scroll = false, clip = true;
float x = 0, y = 0;
};
struct Widget {
Kind kind = Kind::Panel;
std::string id, text;
Layout layout;
Rect rect, clip;
bool visible = true, enabled = true, selected = false, checked = false;
double value = 0, step = .01;
int precision = 3, indent = 0;
float scroll_y = 0, content_height = 0;
std::string error, tooltip;
Json drag_payload;
std::string dock_area, dock_panel;
std::function<void(Widget&)> on_click, on_preview, on_commit, on_cancel;
std::function<void(Widget&, const Json&)> on_drop;
std::vector<std::unique_ptr<Widget>> children;
Widget* parent = nullptr;
Widget& add(Kind, const std::string& stable_id, const std::string& text = {});
Widget* find(std::string_view stable_id);
const Widget* find(std::string_view stable_id) const;
void remove(std::string_view stable_id);
};
// Small persistent docking model: named areas, ordered tabs/panels and explicit
// sizes.
class DockLayout {
public:
void move(const std::string& panel, const std::string& area, std::size_t index);
std::vector<std::string> panels(const std::string& area) const;
void set_size(const std::string& panel, float size);
float size(const std::string& panel, float fallback) const;
Json to_json() const;
void from_json(const Json&);
void save(const std::filesystem::path&) const;
void load(const std::filesystem::path&);
private:
std::unordered_map<std::string, std::vector<std::string>> areas_;
std::unordered_map<std::string, float> sizes_;
};
class Context {
public:
explicit Context(const std::filesystem::path& font);
~Context();
Widget& root();
Widget* find(std::string_view id);
void set_theme(Theme);
const Theme& theme() const;
// Reloads declarative widget properties; matching IDs retain callbacks and
// edit state.
void apply_layout(const Json&);
void layout(float drawable_width, float drawable_height, float dpi_scale = 1);
bool handle(const render::Event&);
void draw(render::Snapshot&);
bool update_text(const std::string& id, const std::string& value, bool force = false);
bool update_number(const std::string& id, double value, bool force = false);
bool focus(const std::string& id);
const std::string& focused_id() const;
void clear_focus(bool commit = true);
bool editing() const;
FontAtlas& font();
void set_clipboard(std::function<std::string()> read,
std::function<void(const std::string&)> write);
// Hook to Renderer::set_text_input and set_text_input_area.
void set_ime(std::function<void(bool)> enabled, std::function<void(Rect)> rectangle);
void set_docking(DockLayout*, std::function<void()> changed = {});
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace faset::ui
+9
View File
@@ -10,3 +10,12 @@ Faset source dependencies are pinned in `dependencies.lock.json`. These notices
- **cgltf** (MIT): [360db1a95480](https://github.com/jkuhlmann/cgltf/tree/360db1a95480fe102ae9c69b27c5d101167ff5ba), notice in `cgltf.txt`. - **cgltf** (MIT): [360db1a95480](https://github.com/jkuhlmann/cgltf/tree/360db1a95480fe102ae9c69b27c5d101167ff5ba), notice in `cgltf.txt`.
- **stb** (MIT OR Unlicense): [2c980bb59875](https://github.com/nothings/stb/tree/2c980bb59875b0d32144a71867fbdebb2f77cd20), notice in `stb.txt`. - **stb** (MIT OR Unlicense): [2c980bb59875](https://github.com/nothings/stb/tree/2c980bb59875b0d32144a71867fbdebb2f77cd20), notice in `stb.txt`.
- **nlohmann/json** (MIT): pinned in the dependency lock, notice in `json.txt`. - **nlohmann/json** (MIT): pinned in the dependency lock, notice in `json.txt`.
- **FreeType** (FreeType License): pinned 2.13.3, notice in `freetype.txt`. Portions of this software are copyright © The FreeType Project (www.freetype.org). All rights reserved.
- **HarfBuzz** (MIT-style): pinned 10.4.0, copyright and permissions in `harfbuzz.txt`.
- **Noto Sans** (SIL Open Font License 1.1): editor font only; see `../assets/fonts/OFL.txt` and `../assets/fonts/NOTICE.md`.
The default Editor build uses the pinned FreeType/HarfBuzz archives. An explicit
`FASET_USE_SYSTEM_TEXT_LIBRARIES=ON` uses installed versions; distributors must retain
the notices appropriate to those installations. These font libraries and the editor
font are not linked into or required by exported games.
+169
View File
@@ -0,0 +1,169 @@
The FreeType Project LICENSE
----------------------------
2006-Jan-27
Copyright 1996-2002, 2006 by
David Turner, Robert Wilhelm, and Werner Lemberg
Introduction
============
The FreeType Project is distributed in several archive packages;
some of them may contain, in addition to the FreeType font engine,
various tools and contributions which rely on, or relate to, the
FreeType Project.
This license applies to all files found in such packages, and
which do not fall under their own explicit license. The license
affects thus the FreeType font engine, the test programs,
documentation and makefiles, at the very least.
This license was inspired by the BSD, Artistic, and IJG
(Independent JPEG Group) licenses, which all encourage inclusion
and use of free software in commercial and freeware products
alike. As a consequence, its main points are that:
o We don't promise that this software works. However, we will be
interested in any kind of bug reports. (`as is' distribution)
o You can use this software for whatever you want, in parts or
full form, without having to pay us. (`royalty-free' usage)
o You may not pretend that you wrote this software. If you use
it, or only parts of it, in a program, you must acknowledge
somewhere in your documentation that you have used the
FreeType code. (`credits')
We specifically permit and encourage the inclusion of this
software, with or without modifications, in commercial products.
We disclaim all warranties covering The FreeType Project and
assume no liability related to The FreeType Project.
Finally, many people asked us for a preferred form for a
credit/disclaimer to use in compliance with this license. We thus
encourage you to use the following text:
"""
Portions of this software are copyright © <year> The FreeType
Project (www.freetype.org). All rights reserved.
"""
Please replace <year> with the value from the FreeType version you
actually use.
Legal Terms
===========
0. Definitions
--------------
Throughout this license, the terms `package', `FreeType Project',
and `FreeType archive' refer to the set of files originally
distributed by the authors (David Turner, Robert Wilhelm, and
Werner Lemberg) as the `FreeType Project', be they named as alpha,
beta or final release.
`You' refers to the licensee, or person using the project, where
`using' is a generic term including compiling the project's source
code as well as linking it to form a `program' or `executable'.
This program is referred to as `a program using the FreeType
engine'.
This license applies to all files distributed in the original
FreeType Project, including all source code, binaries and
documentation, unless otherwise stated in the file in its
original, unmodified form as distributed in the original archive.
If you are unsure whether or not a particular file is covered by
this license, you must contact us to verify this.
The FreeType Project is copyright (C) 1996-2000 by David Turner,
Robert Wilhelm, and Werner Lemberg. All rights reserved except as
specified below.
1. No Warranty
--------------
THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS
BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO
USE, OF THE FREETYPE PROJECT.
2. Redistribution
-----------------
This license grants a worldwide, royalty-free, perpetual and
irrevocable right and license to use, execute, perform, compile,
display, copy, create derivative works of, distribute and
sublicense the FreeType Project (in both source and object code
forms) and derivative works thereof for any purpose; and to
authorize others to exercise some or all of the rights granted
herein, subject to the following conditions:
o Redistribution of source code must retain this license file
(`FTL.TXT') unaltered; any additions, deletions or changes to
the original files must be clearly indicated in accompanying
documentation. The copyright notices of the unaltered,
original files must be preserved in all copies of source
files.
o Redistribution in binary form must provide a disclaimer that
states that the software is based in part of the work of the
FreeType Team, in the distribution documentation. We also
encourage you to put an URL to the FreeType web page in your
documentation, though this isn't mandatory.
These conditions apply to any software derived from or based on
the FreeType Project, not just the unmodified files. If you use
our work, you must acknowledge us. However, no fee need be paid
to us.
3. Advertising
--------------
Neither the FreeType authors and contributors nor you shall use
the name of the other for commercial, advertising, or promotional
purposes without specific prior written permission.
We suggest, but do not require, that you use one or more of the
following phrases to refer to this software in your documentation
or advertising materials: `FreeType Project', `FreeType Engine',
`FreeType library', or `FreeType Distribution'.
As you have not signed this license, you are not required to
accept it. However, as the FreeType Project is copyrighted
material, only this license, or another one contracted with the
authors, grants you the right to use, distribute, and modify it.
Therefore, by using, distributing, or modifying the FreeType
Project, you indicate that you understand and accept all the terms
of this license.
4. Contacts
-----------
There are two mailing lists related to FreeType:
o freetype@nongnu.org
Discusses general use and applications of FreeType, as well as
future and wanted additions to the library and distribution.
If you are looking for support, start in this list if you
haven't found anything to help you in the documentation.
o freetype-devel@nongnu.org
Discusses bugs, as well as engine internals, design issues,
specific licenses, porting, etc.
Our home page can be found at
https://www.freetype.org
--- end of FTL.TXT ---
+42
View File
@@ -0,0 +1,42 @@
HarfBuzz is licensed under the so-called "Old MIT" license. Details follow.
For parts of HarfBuzz that are licensed under different licenses see individual
files names COPYING in subdirectories where applicable.
Copyright © 2010-2022 Google, Inc.
Copyright © 2015-2020 Ebrahim Byagowi
Copyright © 2019,2020 Facebook, Inc.
Copyright © 2012,2015 Mozilla Foundation
Copyright © 2011 Codethink Limited
Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies)
Copyright © 2009 Keith Stribley
Copyright © 2011 Martin Hosken and SIL International
Copyright © 2007 Chris Wilson
Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod
Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc.
Copyright © 1998-2005 David Turner and Werner Lemberg
Copyright © 2016 Igalia S.L.
Copyright © 2022 Matthias Clasen
Copyright © 2018,2021 Khaled Hosny
Copyright © 2018,2019,2020 Adobe, Inc
Copyright © 2013-2015 Alexei Podtelezhnikov
For full copyright notices consult the individual files in the package.
Permission is hereby granted, without written agreement and without
license or royalty fees, to use, copy, modify, and distribute this
software and its documentation for any purpose, provided that the
above copyright notice and the following two paragraphs appear in
all copies of this software.
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR
DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN
IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGE.
THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS
ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO
PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+6
View File
@@ -1,3 +1,9 @@
nlohmann/json v3.12.0
SPDX-FileCopyrightText: 2013 - 2025 Niels Lohmann <https://nlohmann.me>
SPDX-License-Identifier: MIT
The following license text is reproduced from upstream LICENSES/MIT.txt.
MIT License MIT License
Copyright (c) <year> <copyright holders> Copyright (c) <year> <copyright holders>
+10
View File
@@ -22,6 +22,9 @@ plugins:
- search - search
markdown_extensions: markdown_extensions:
- admonition - admonition
- pymdownx.snippets:
base_path: ["."]
check_paths: true
- pymdownx.details - pymdownx.details
- pymdownx.superfences - pymdownx.superfences
- pymdownx.highlight: - pymdownx.highlight:
@@ -31,5 +34,12 @@ nav:
- Build from source: getting-started/build.md - Build from source: getting-started/build.md
- C++ gameplay: - C++ gameplay:
- How gameplay works: scripting/index.md - How gameplay works: scripting/index.md
- Write your first behavior: scripting/first-behavior.md
- Frame and physics updates: scripting/lifecycle.md - Frame and physics updates: scripting/lifecycle.md
- Physics and grounded movement: scripting/physics.md
- Runtime API: scripting/api.md
- Compiled examples: scripting/examples.md
- Editor automation:
- MCP and command line: editor/mcp.md
- Native extensions: editor/extensions.md
- Contributing to this manual: contributing.md - Contributing to this manual: contributing.md
+7 -2
View File
@@ -34,8 +34,13 @@ VertexOutput vertexMain(VertexInput v) {
float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); } float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); }
[shader("fragment")] [shader("fragment")]
float4 fragmentMain(VertexOutput v) : SV_Target { float4 fragmentMain(VertexOutput v) : SV_Target {
float4 base = v.color * colorMap.Sample(colorSampler, v.uv); float4 sampled = colorMap.Sample(colorSampler, v.uv);
if (dot(v.normal,v.normal) < 0.01) return base; if (dot(v.normal,v.normal) < 1e-12) {
// UI/sprite tint is in display space; sRGB textures were decoded by Vulkan.
if (v.material.x > 0.5) sampled.rgb = lerp(sampled.rgb * 12.92, 1.055 * pow(max(sampled.rgb,0),float3(1.0/2.4)) - 0.055, step(0.0031308, sampled.rgb));
return v.color * sampled;
}
float4 base = v.color * sampled;
const float pi = 3.14159265; const float pi = 3.14159265;
float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view); float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view);
float nl=max(dot(n,l),0.0), nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0); float nl=max(dot(n,l),0.0), nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0);
+203
View File
@@ -0,0 +1,203 @@
#include <algorithm>
#include <bit>
#include <cctype>
#include <cmath>
#include <faset/assets/asset_data.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <fstream>
#include <set>
#include <stdexcept>
namespace faset::assets {
namespace fs = std::filesystem;
namespace {
void valid_id(const std::string& id) {
if (id.empty() || id.size() > 128 || !std::all_of(id.begin(), id.end(), [](unsigned char c) {
return std::isalnum(c) || c == '-' || c == '_';
}))
throw std::runtime_error("Invalid AssetId");
}
std::vector<std::byte> read_bytes(const fs::path& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file)
throw std::runtime_error("Cannot read cooked file: " + path.string());
auto length = file.tellg();
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
throw std::runtime_error("Cooked file exceeds 1 GiB limit");
std::vector<std::byte> bytes(static_cast<std::size_t>(length));
file.seekg(0);
if (!bytes.empty() && !file.read(reinterpret_cast<char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size())))
throw std::runtime_error("Truncated cooked file");
return bytes;
}
Json read_json(const fs::path& path) {
auto bytes = read_bytes(path);
if (bytes.empty())
throw std::runtime_error("Empty cooked JSON manifest");
return Json::parse(reinterpret_cast<const char*>(bytes.data()),
reinterpret_cast<const char*>(bytes.data() + bytes.size()));
}
fs::path cooked_path(const fs::path& directory, const std::string& name) {
if (name.empty())
throw std::runtime_error("Empty cooked path");
return faset::project_path(directory, fs::path(name));
}
void validate_generation(const fs::path& directory, const Json& manifest) {
if (manifest.at("schema_version") != 1)
throw std::runtime_error("Unsupported asset manifest version");
std::set<std::string> files;
for (const auto& file : manifest.at("files")) {
auto name = file.at("path").get<std::string>();
if (!files.insert(name).second)
throw std::runtime_error("Duplicate cooked file path");
auto bytes = read_bytes(cooked_path(directory, name));
if (bytes.size() != file.at("size").get<std::size_t>() ||
faset::sha256(std::span<const std::byte>(bytes)) !=
file.at("sha256").get<std::string>())
throw std::runtime_error("Corrupt cooked file: " + name);
}
for (const auto& mesh : manifest.at("meshes"))
for (const auto& primitive : mesh.at("primitives"))
if (!files.contains(primitive.at("path").get<std::string>()))
throw std::runtime_error("Mesh payload is missing from its manifest");
for (const auto& texture : manifest.at("textures"))
if (!files.contains(texture.at("path").get<std::string>()))
throw std::runtime_error("Texture payload is missing from its manifest");
}
struct BinaryReader {
const std::vector<std::byte>& bytes;
std::size_t cursor = 0;
std::uint32_t u32() {
if (bytes.size() - cursor < 4)
throw std::runtime_error("Truncated cooked mesh");
std::uint32_t v = 0;
for (int i = 0; i < 4; ++i)
v |= std::to_integer<std::uint32_t>(bytes[cursor++]) << (8 * i);
return v;
}
float number() {
auto v = std::bit_cast<float>(u32());
if (!std::isfinite(v))
throw std::runtime_error("Invalid cooked float");
return v;
}
};
Primitive decode_primitive(const std::vector<std::byte>& bytes, int material) {
BinaryReader in{bytes};
if (in.u32() != 0x48534d46 || in.u32() != 1)
throw std::runtime_error("Unsupported cooked mesh format");
const auto nv = in.u32(), ni = in.u32();
if (static_cast<std::uint64_t>(nv) * 32 + static_cast<std::uint64_t>(ni) * 4 + 16 !=
bytes.size())
throw std::runtime_error("Invalid cooked mesh size");
Primitive p;
p.material = material;
p.vertices.resize(nv);
p.indices.resize(ni);
for (auto& v : p.vertices) {
for (auto& x : v.position)
x = in.number();
for (auto& x : v.normal)
x = in.number();
for (auto& x : v.uv)
x = in.number();
}
for (auto& i : p.indices) {
i = in.u32();
if (i >= nv)
throw std::runtime_error("Cooked mesh index out of range");
}
return p;
}
} // namespace
AssetStore::AssetStore(fs::path root)
: cache_root_(fs::absolute(std::move(root)).lexically_normal()) {}
fs::path AssetStore::generation_directory(const std::string& id) const {
valid_id(id);
const auto root = faset::project_path(cache_root_, fs::path("assets") / id);
const auto pointer = read_json(root / "current.json");
const auto generation = pointer.at("generation").get<std::string>();
valid_id(generation);
return faset::project_path(root, fs::path("generations") / generation);
}
Json AssetStore::current_manifest(const std::string& id) const {
valid_id(id);
const auto root = faset::project_path(cache_root_, fs::path("assets") / id);
const auto pointer = read_json(root / "current.json");
const auto generation = pointer.at("generation").get<std::string>();
valid_id(generation);
auto manifest = read_json(
faset::project_path(root, fs::path("generations") / generation / "manifest.json"));
if (manifest.at("asset_id").get<std::string>() != id ||
manifest.at("generation").get<std::string>() != generation)
throw std::runtime_error("Cooked manifest identity does not match its generation");
// One pointer snapshot prevents mixing two concurrently published generations.
manifest["source"] = pointer.at("source");
return manifest;
}
CookedAsset AssetStore::load_asset(const std::string& id) const {
const auto directory = generation_directory(id);
const auto m = read_json(directory / "manifest.json");
validate_generation(directory, m);
if (m.at("asset_id").get<std::string>() != id ||
m.at("generation").get<std::string>() != directory.filename().string())
throw std::runtime_error("Cooked asset identity does not match its generation");
CookedAsset asset;
asset.asset_id = m.at("asset_id");
asset.generation = m.at("generation");
for (const auto& n : m.at("nodes")) {
Node node;
node.id = n.at("id");
node.name = n.at("name");
node.parent_id = n.at("parent_id");
node.mesh = n.at("mesh");
node.local_transform = n.at("local_transform").get<std::array<float, 16>>();
node.stable_source_id = n.at("stable_source_id");
asset.nodes.push_back(std::move(node));
}
for (const auto& j : m.at("meshes")) {
Mesh mesh;
mesh.id = j.at("id");
mesh.name = j.at("name");
for (const auto& primitive : j.at("primitives"))
mesh.primitives.push_back(decode_primitive(
read_bytes(cooked_path(directory, primitive.at("path").get<std::string>())),
primitive.at("material")));
asset.meshes.push_back(std::move(mesh));
}
for (const auto& j : m.at("materials")) {
Material material;
material.id = j.at("id");
material.name = j.at("name");
material.base_color = j.at("base_color").get<std::array<float, 4>>();
material.emissive = j.at("emissive").get<std::array<float, 3>>();
material.metallic = j.at("metallic");
material.roughness = j.at("roughness");
material.alpha_mode = j.at("alpha_mode");
material.alpha_cutoff = j.at("alpha_cutoff");
material.double_sided = j.at("double_sided");
material.unlit = j.at("unlit");
material.base_color_texture = j.at("base_color_texture");
material.metallic_roughness_texture = j.at("metallic_roughness_texture");
material.normal_texture = j.at("normal_texture");
material.occlusion_texture = j.at("occlusion_texture");
material.emissive_texture = j.at("emissive_texture");
asset.materials.push_back(std::move(material));
}
for (const auto& j : m.at("textures")) {
Texture texture;
texture.id = j.at("id");
texture.name = j.at("name");
texture.mime_type = j.at("mime_type");
texture.bytes = read_bytes(cooked_path(directory, j.at("path").get<std::string>()));
texture.wrap_s = j.at("wrap_s");
texture.wrap_t = j.at("wrap_t");
texture.min_filter = j.at("min_filter");
texture.mag_filter = j.at("mag_filter");
asset.textures.push_back(std::move(texture));
}
return asset;
}
} // namespace faset::assets
File diff suppressed because it is too large Load Diff
+181 -94
View File
@@ -1,120 +1,207 @@
#include <faset/authoring/schema.hpp>
#include <array> #include <array>
#include <cmath> #include <cmath>
#include <faset/authoring/schema.hpp>
#include <set> #include <set>
namespace faset::authoring { namespace faset::authoring {
void validate_field(const Json& value,const Json& descriptor) { void validate_field(const Json& value, const Json& descriptor) {
const auto kind=descriptor.value("type",std::string("any")); const auto kind = descriptor.value("type", std::string("any"));
bool valid=true; bool valid = true;
if(kind=="number"||kind=="float") valid=value.is_number()&&std::isfinite(value.get<double>()); if (kind == "number" || kind == "float")
else if(kind=="integer"||kind=="int") valid=value.is_number_integer(); valid = value.is_number() && std::isfinite(value.get<double>());
else if(kind=="boolean"||kind=="bool") valid=value.is_boolean(); else if (kind == "integer" || kind == "int")
else if(kind=="string"||kind=="asset_ref"||kind=="entity_ref") valid=value.is_string(); valid = value.is_number_integer();
else if(kind=="vec2"||kind=="vec3"||kind=="vec4"||kind=="color") { else if (kind == "boolean" || kind == "bool")
const auto size=kind=="vec2"?2u:(kind=="vec3"?3u:4u); valid = value.is_boolean();
valid=value.is_array()&&value.size()==size; else if (kind == "string" || kind == "asset_ref" || kind == "entity_ref")
if(valid) for(const auto& entry:value) valid=valid&&entry.is_number()&&std::isfinite(entry.get<double>()); valid = value.is_string();
} else if(kind=="array") valid=value.is_array(); else if (kind == "vec2" || kind == "vec3" || kind == "vec4" || kind == "color") {
else if(kind=="object") valid=value.is_object(); const auto size = kind == "vec2" ? 2u : (kind == "vec3" ? 3u : 4u);
else require(kind=="any","schema.field_type","Unsupported schema field type: "+kind); valid = value.is_array() && value.size() == size;
require(valid,"validation.field_type","Invalid value for field "+descriptor.value("id",std::string("?"))+" (expected "+kind+")"); if (valid)
if(value.is_number()) { for (const auto& entry : value)
if(descriptor.contains("min")) require(value.get<double>()>=descriptor["min"].get<double>(),"validation.minimum","Field is below its minimum"); valid = valid && entry.is_number() && std::isfinite(entry.get<double>());
if(descriptor.contains("max")) require(value.get<double>()<=descriptor["max"].get<double>(),"validation.maximum","Field exceeds its maximum"); } else if (kind == "array")
valid = value.is_array();
else if (kind == "object")
valid = value.is_object();
else
require(kind == "any", "schema.field_type", "Unsupported schema field type: " + kind);
require(valid, "validation.field_type",
"Invalid value for field " + descriptor.value("id", std::string("?")) + " (expected " +
kind + ")");
if (value.is_number()) {
if (descriptor.contains("min"))
require(value.get<double>() >= descriptor["min"].get<double>(), "validation.minimum",
"Field is below its minimum");
if (descriptor.contains("max"))
require(value.get<double>() <= descriptor["max"].get<double>(), "validation.maximum",
"Field exceeds its maximum");
} }
if(descriptor.contains("enum")) { if (descriptor.contains("enum")) {
bool found=false;for(const auto& option:descriptor["enum"])found=found||option==value; bool found = false;
require(found,"validation.enum","Field value is not an allowed choice"); for (const auto& option : descriptor["enum"])
found = found || option == value;
require(found, "validation.enum", "Field value is not an allowed choice");
} }
} }
void SchemaRegistry::register_schema(const Json& value) { void SchemaRegistry::register_schema(const Json& value) {
require(value.is_object()&&value.contains("id")&&value["id"].is_string()&&value.contains("fields")&&value["fields"].is_object(),"schema.invalid","Invalid component schema"); require(value.is_object() && value.contains("id") && value["id"].is_string() &&
Json normalized=value; value.contains("fields") && value["fields"].is_object(),
const auto id=value.at("id").get<std::string>(); "schema.invalid", "Invalid component schema");
require(!id.empty(),"schema.invalid","TypeId cannot be empty"); Json normalized = value;
require(value.value("version",1)>0,"schema.invalid","Schema version must be positive"); const auto id = value.at("id").get<std::string>();
for(auto& [key,field]:normalized["fields"].items()) { require(!id.empty(), "schema.invalid", "TypeId cannot be empty");
require(field.is_object()&&field.contains("default"),"schema.invalid","Each field requires a typed default"); require(value.value("version", 1) > 0, "schema.invalid", "Schema version must be positive");
require(field.value("id",key)==key,"schema.field_id","Field map keys must be stable FieldIds"); for (auto& [key, field] : normalized["fields"].items()) {
field["id"]=key;validate_field(field["default"],field); require(field.is_object() && field.contains("default"), "schema.invalid",
"Each field requires a typed default");
require(field.value("id", key) == key, "schema.field_id",
"Field map keys must be stable FieldIds");
field["id"] = key;
validate_field(field["default"], field);
} }
if(auto found=schemas_.find(id);found!=schemas_.end()) require(found->second==normalized,"schema.duplicate_type","A different schema is already registered for "+id); if (auto found = schemas_.find(id); found != schemas_.end())
schemas_[id]=std::move(normalized); require(found->second == normalized, "schema.duplicate_type",
"A different schema is already registered for " + id);
schemas_[id] = std::move(normalized);
} }
void SchemaRegistry::register_schemas(const Json& values) { void SchemaRegistry::register_schemas(const Json& values) {
const auto& array=values.is_array()?values:values.at("types"); const auto& array = values.is_array() ? values : values.at("types");
auto candidate=*this;for(const auto& schema:array)candidate.register_schema(schema);*this=std::move(candidate); auto candidate = *this;
for (const auto& schema : array)
candidate.register_schema(schema);
*this = std::move(candidate);
} }
bool SchemaRegistry::contains(const std::string& type)const{return schemas_.contains(type);} bool SchemaRegistry::contains(const std::string& type) const {
Json SchemaRegistry::schema(const std::string& type)const { return schemas_.contains(type);
const auto found=schemas_.find(type);require(found!=schemas_.end(),"schema.missing","Component schema unavailable: "+type);return found->second;
} }
Json SchemaRegistry::manifest()const {Json types=Json::array();for(const auto& [id,type]:schemas_)types.push_back(type);return {{"format","faset.schema"},{"version",1},{"types",types}};} Json SchemaRegistry::schema(const std::string& type) const {
Json SchemaRegistry::default_fields(const std::string& type)const {Json fields=Json::object();const auto metadata=schema(type);for(const auto& [id,field]:metadata["fields"].items())fields[id]=field["default"];return fields;} const auto found = schemas_.find(type);
void SchemaRegistry::validate_component(const Json& component)const { require(found != schemas_.end(), "schema.missing", "Component schema unavailable: " + type);
require(component.is_object()&&component.contains("type")&&component["type"].is_string()&&component.contains("fields")&&component["fields"].is_object(),"component.invalid","Invalid component record"); return found->second;
const auto type=component.at("type").get<std::string>(); }
if(!contains(type))return; Json SchemaRegistry::manifest() const {
const auto metadata=schema(type); Json types = Json::array();
for (const auto& [id, type] : schemas_)
types.push_back(type);
return {{"format", "faset.schema"}, {"version", 1}, {"types", types}};
}
Json SchemaRegistry::default_fields(const std::string& type) const {
Json fields = Json::object();
const auto metadata = schema(type);
for (const auto& [id, field] : metadata["fields"].items())
fields[id] = field["default"];
return fields;
}
void SchemaRegistry::validate_component(const Json& component) const {
require(component.is_object() && component.contains("type") && component["type"].is_string() &&
component.contains("fields") && component["fields"].is_object(),
"component.invalid", "Invalid component record");
const auto type = component.at("type").get<std::string>();
if (!contains(type))
return;
const auto metadata = schema(type);
// Future or missing-module schemas are preserved, not interpreted with the wrong version. // Future or missing-module schemas are preserved, not interpreted with the wrong version.
if(component.value("version",1)!=metadata.value("version",1))return; if (component.value("version", 1) != metadata.value("version", 1))
for(const auto& [id,value]:component["fields"].items())if(metadata["fields"].contains(id))validate_field(value,metadata["fields"][id]); return;
for (const auto& [id, value] : component["fields"].items())
if (metadata["fields"].contains(id))
validate_field(value, metadata["fields"][id]);
} }
void SchemaRegistry::add_migration(const std::string& type,int from_version,Json rules) { void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) {
require(from_version>0&&rules.is_object(),"migration.invalid","Invalid migration"); require(from_version > 0 && rules.is_object(), "migration.invalid", "Invalid migration");
require(!migrations_.contains({type,from_version}),"migration.duplicate","Migration already exists"); require(!migrations_.contains({type, from_version}), "migration.duplicate",
migrations_[{type,from_version}]=std::move(rules); "Migration already exists");
migrations_[{type, from_version}] = std::move(rules);
} }
Json SchemaRegistry::migrate_component(const Json& source)const { Json SchemaRegistry::migrate_component(const Json& source) const {
Json result=source;const auto type=result.at("type").get<std::string>(); Json result = source;
if(!contains(type))return result; const auto type = result.at("type").get<std::string>();
const auto current=schema(type).value("version",1); if (!contains(type))
auto version=result.value("version",1); return result;
if(version>current)return result; const auto current = schema(type).value("version", 1);
while(version<current) { auto version = result.value("version", 1);
const auto found=migrations_.find({type,version}); if (version > current)
require(found!=migrations_.end(),"migration.required","Explicit migration required for "+type); return result;
for(const auto& [field,rule]:found->second.items()) { while (version < current) {
if(rule.contains("default")&&!result["fields"].contains(field))result["fields"][field]=rule["default"]; const auto found = migrations_.find({type, version});
if(rule.contains("scale")&&result["fields"].contains(field)) { require(found != migrations_.end(), "migration.required",
require(result["fields"][field].is_number(),"migration.type","Cannot scale a nonnumeric field"); "Explicit migration required for " + type);
result["fields"][field]=result["fields"][field].get<double>()*rule["scale"].get<double>(); for (const auto& [field, rule] : found->second.items()) {
if (rule.contains("default") && !result["fields"].contains(field))
result["fields"][field] = rule["default"];
if (rule.contains("scale") && result["fields"].contains(field)) {
require(result["fields"][field].is_number(), "migration.type",
"Cannot scale a nonnumeric field");
result["fields"][field] =
result["fields"][field].get<double>() * rule["scale"].get<double>();
} }
if(rule.value("require_manual",false)&&result["fields"].contains(field))throw Error("migration.manual","Field requires explicit manual migration",{{"type",type},{"field",field}}); if (rule.value("require_manual", false) && result["fields"].contains(field))
throw Error("migration.manual", "Field requires explicit manual migration",
{{"type", type}, {"field", field}});
} }
result["version"]=++version; result["version"] = ++version;
} }
const auto metadata=schema(type); const auto metadata = schema(type);
for(const auto& [field,descriptor]:metadata["fields"].items())if(!result["fields"].contains(field))result["fields"][field]=descriptor["default"]; for (const auto& [field, descriptor] : metadata["fields"].items())
validate_component(result);return result; if (!result["fields"].contains(field))
result["fields"][field] = descriptor["default"];
validate_component(result);
return result;
} }
SchemaRegistry builtin_schemas() { SchemaRegistry builtin_schemas() {
SchemaRegistry registry; SchemaRegistry registry;
struct Transform {std::array<float,3> position,rotation,scale;}; struct Transform {
TypeRegistration<Transform>(registry,"faset.transform","Transform") std::array<float, 3> position, rotation, scale;
.field("position","Position",&Transform::position,std::array<float,3>{0,0,0},"vec3") };
.field("rotation","Rotation",&Transform::rotation,std::array<float,3>{0,0,0},"vec3",{{"unit","radians"}}) TypeRegistration<Transform>(registry, "faset.transform", "Transform")
.field("scale","Scale",&Transform::scale,std::array<float,3>{1,1,1},"vec3").commit(); .field("position", "Position", &Transform::position, std::array<float, 3>{0, 0, 0}, "vec3")
auto add=[&](std::string id,std::string name,Json fields){registry.register_schema({{"id",id},{"name",name},{"version",1},{"fields",fields}});}; .field("rotation", "Rotation", &Transform::rotation, std::array<float, 3>{0, 0, 0}, "vec3",
auto field=[](std::string type,Json value){return Json{{"type",type},{"default",value}};}; {{"unit", "radians"}})
add("faset.sprite","Sprite",{{"color",field("color",{0.65,0.6,0.85,1.0})},{"size",field("vec2",{1,1})},{"texture",field("asset_ref","")},{"layer",field("integer",0)}}); .field("scale", "Scale", &Transform::scale, std::array<float, 3>{1, 1, 1}, "vec3")
add("faset.mesh","Mesh",{{"asset",field("asset_ref","")},{"color",field("color",{0.65,0.65,0.68,1.0})},{"primitive",Json{{"type","string"},{"default","cube"},{"enum",{"cube","plane","asset"}}}}}); .commit();
add("faset.camera","Camera",{{"fov",Json{{"type","number"},{"default",60.0},{"min",1.0},{"max",179.0}}},{"near",Json{{"type","number"},{"default",0.1},{"min",0.001}}},{"far",Json{{"type","number"},{"default",1000.0},{"min",0.01}}}}); auto add = [&](std::string id, std::string name, Json fields) {
add("faset.light","Directional Light",{{"color",field("color",{1,1,1,1})},{"intensity",Json{{"type","number"},{"default",1.0},{"min",0.0}}}}); registry.register_schema({{"id", id}, {"name", name}, {"version", 1}, {"fields", fields}});
for(int dimension:{2,3}) { };
Json vector=dimension==2?Json{0,0}:Json{0,0,0};Json extents=dimension==2?Json{0.5,0.5}:Json{0.5,0.5,0.5}; auto field = [](std::string type, Json value) {
add("faset.rigid_body_"+std::to_string(dimension)+"d","Rigid Body "+std::to_string(dimension)+"D",{ return Json{{"type", type}, {"default", value}};
{"body_type",Json{{"type","string"},{"default","dynamic"},{"enum",{"static","dynamic","kinematic"}}}}, };
{"half_extents",field(dimension==2?"vec2":"vec3",extents)}, add("faset.sprite", "Sprite",
{"linear_velocity",field(dimension==2?"vec2":"vec3",vector)}, {{"color", field("color", {0.65, 0.6, 0.85, 1.0})},
{"density",Json{{"type","number"},{"default",1.0},{"min",0.001}}}, {"size", field("vec2", {1, 1})},
{"friction",Json{{"type","number"},{"default",0.5},{"min",0.0}}}, {"texture", field("asset_ref", "")},
{"restitution",Json{{"type","number"},{"default",0.0},{"min",0.0},{"max",1.0}}}, {"layer", field("integer", 0)}});
{"gravity_scale",field("number",1.0)}, add("faset.mesh", "Mesh",
{"category_bits",Json{{"type","integer"},{"default",1},{"min",0}}}, {{"asset", field("asset_ref", "")},
{"mask_bits",Json{{"type","integer"},{"default",65535},{"min",0}}}}); {"color", field("color", {0.65, 0.65, 0.68, 1.0})},
{"primitive",
Json{{"type", "string"}, {"default", "cube"}, {"enum", {"cube", "plane", "asset"}}}}});
add("faset.camera", "Camera",
{{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}},
{"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}},
{"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}});
add("faset.light", "Directional Light",
{{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}});
for (int dimension : {2, 3}) {
Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0};
Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5};
add("faset.rigid_body_" + std::to_string(dimension) + "d",
"Rigid Body " + std::to_string(dimension) + "D",
{{"body_type", Json{{"type", "string"},
{"default", "dynamic"},
{"enum", {"static", "dynamic", "kinematic"}}}},
{"half_extents", field(dimension == 2 ? "vec2" : "vec3", extents)},
{"linear_velocity", field(dimension == 2 ? "vec2" : "vec3", vector)},
{"density", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.001}}},
{"friction", Json{{"type", "number"}, {"default", 0.5}, {"min", 0.0}}},
{"restitution",
Json{{"type", "number"}, {"default", 0.0}, {"min", 0.0}, {"max", 1.0}}},
{"gravity_scale", field("number", 1.0)},
{"category_bits", Json{{"type", "integer"}, {"default", 1}, {"min", 0}}},
{"mask_bits", Json{{"type", "integer"}, {"default", 65535}, {"min", 0}}}});
} }
return registry; return registry;
} }
} } // namespace faset::authoring
+474 -164
View File
@@ -1,201 +1,511 @@
#include <faset/authoring/service.hpp>
#include <faset/core/io.hpp>
#include <faset/core/hash.hpp>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <faset/authoring/service.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <set> #include <set>
namespace faset::authoring { namespace faset::authoring {
namespace { namespace {
Json& entity(Json& scene,const std::string& id) { Json& entity(Json& scene, const std::string& id) {
for(auto& item:scene["entities"])if(item.at("id")==id)return item; for (auto& item : scene["entities"])
throw Error("entity.missing","Entity does not exist",{{"entity",id}}); if (item.at("id") == id)
return item;
throw Error("entity.missing", "Entity does not exist", {{"entity", id}});
} }
Json& component(Json& item,const std::string& id) { Json& component(Json& item, const std::string& id) {
for(auto& value:item["components"])if(value.at("id")==id)return value; for (auto& value : item["components"])
throw Error("component.missing","Component does not exist",{{"component",id}}); if (value.at("id") == id)
return value;
throw Error("component.missing", "Component does not exist", {{"component", id}});
} }
std::string parent_id(const Json& item) {return item.contains("parent")&&!item["parent"].is_null()?item["parent"].get<std::string>():"";} std::string parent_id(const Json& item) {
void check_revision(std::uint64_t current,std::uint64_t expected) { return item.contains("parent") && !item["parent"].is_null() ? item["parent"].get<std::string>()
if(current!=expected)throw Error("revision.conflict","Document changed since it was read",{{"expected",expected},{"current",current}}); : "";
}
void check_revision(std::uint64_t current, std::uint64_t expected) {
if (current != expected)
throw Error("revision.conflict", "Document changed since it was read",
{{"expected", expected}, {"current", current}});
} }
bool finite_json(const Json& value) { bool finite_json(const Json& value) {
if(value.is_number_float())return std::isfinite(value.get<double>()); if (value.is_number_float())
if(value.is_structured())for(const auto& child:value)if(!finite_json(child))return false; return std::isfinite(value.get<double>());
if (value.is_structured())
for (const auto& child : value)
if (!finite_json(child))
return false;
return true; return true;
} }
bool valid_id(const Json& value) {
if (!value.is_string())
return false;
const auto& text = value.get_ref<const std::string&>();
return !text.empty() && text.size() <= 128 &&
text.find_first_not_of(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:") ==
std::string::npos;
} }
Json make_scene(std::string name,int dimension) { } // namespace
require(dimension==2||dimension==3,"scene.dimension","Scene dimension must be 2 or 3"); Json make_scene(std::string name, int dimension) {
return {{"format","faset.scene"},{"version",1},{"id",new_id()},{"name",std::move(name)},{"dimension",dimension},{"entities",Json::array()},{"instances",Json::array()}}; require(dimension == 2 || dimension == 3, "scene.dimension", "Scene dimension must be 2 or 3");
return {{"format", "faset.scene"}, {"version", 1}, {"id", new_id()},
{"name", std::move(name)}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}};
} }
Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent) { Json make_entity(const SchemaRegistry& schemas, std::string name, const std::string& parent) {
Json transform={{"id",new_id()},{"type","faset.transform"},{"version",1},{"fields",schemas.default_fields("faset.transform")}}; Json transform = {{"id", new_id()},
return {{"id",new_id()},{"name",std::move(name)},{"parent",parent.empty()?Json(nullptr):Json(parent)},{"components",Json::array({transform})}}; {"type", "faset.transform"},
{"version", 1},
{"fields", schemas.default_fields("faset.transform")}};
return {{"id", new_id()},
{"name", std::move(name)},
{"parent", parent.empty() ? Json(nullptr) : Json(parent)},
{"components", Json::array({transform})}};
} }
void validate_scene(const Json& scene,const SchemaRegistry& schemas) { void validate_scene(const Json& scene, const SchemaRegistry& schemas) {
require(scene.is_object()&&scene.value("format",std::string())=="faset.scene","scene.format","Expected a Faset scene"); require(scene.is_object() && scene.value("format", std::string()) == "faset.scene",
require(scene.value("version",0)==1,"scene.version","Unsupported scene format version"); "scene.format", "Expected a Faset scene");
require(scene.contains("id")&&scene["id"].is_string()&&!scene["id"].get<std::string>().empty(),"scene.id","Scene requires a stable ID"); require(scene.value("version", 0) == 1, "scene.version", "Unsupported scene format version");
require(scene.contains("name")&&scene["name"].is_string(),"scene.name","Scene name must be text"); require(scene.contains("id") && valid_id(scene["id"]), "scene.id",
require(scene.value("dimension",0)==2||scene.value("dimension",0)==3,"scene.dimension","Scene dimension must be 2 or 3"); "Scene requires a safe stable ID");
require(scene.contains("entities")&&scene["entities"].is_array(),"scene.entities","Scene entities must be an array"); require(scene.contains("name") && scene["name"].is_string(), "scene.name",
require(finite_json(scene),"validation.finite","Scene contains a non-finite number"); "Scene name must be text");
std::set<std::string> ids;std::map<std::string,std::string> parents; require(scene.value("dimension", 0) == 2 || scene.value("dimension", 0) == 3, "scene.dimension",
auto insert_id=[&](const Json& value) {require(value.is_string()&&!value.get<std::string>().empty(),"id.invalid","ID must be nonempty text");require(ids.insert(value.get<std::string>()).second,"id.duplicate","Duplicate document ID");}; "Scene dimension must be 2 or 3");
for(const auto& item:scene["entities"]) { require(scene.contains("entities") && scene["entities"].is_array(), "scene.entities",
require(item.is_object()&&item.contains("id")&&item.contains("name")&&item["name"].is_string(),"entity.invalid","Invalid entity record"); "Scene entities must be an array");
insert_id(item["id"]);parents[item["id"].get<std::string>()]=parent_id(item); require(finite_json(scene), "validation.finite", "Scene contains a non-finite number");
require(item.contains("components")&&item["components"].is_array(),"entity.components","Entity components must be an array"); std::set<std::string> ids;
std::map<std::string, std::string> parents;
auto insert_id = [&](const Json& value) {
require(valid_id(value), "id.invalid",
"ID must contain at most 128 ASCII identifier characters");
require(ids.insert(value.get<std::string>()).second, "id.duplicate",
"Duplicate document ID");
};
for (const auto& item : scene["entities"]) {
require(item.is_object() && item.contains("id") && item.contains("name") &&
item["name"].is_string(),
"entity.invalid", "Invalid entity record");
insert_id(item["id"]);
parents[item["id"].get<std::string>()] = parent_id(item);
require(item.contains("components") && item["components"].is_array(), "entity.components",
"Entity components must be an array");
std::set<std::string> types; std::set<std::string> types;
for(const auto& value:item["components"]) { for (const auto& value : item["components"]) {
require(value.contains("id"),"component.id","Component requires stable ID");insert_id(value["id"]);schemas.validate_component(value); require(value.contains("id"), "component.id", "Component requires stable ID");
require(types.insert(value.at("type").get<std::string>()).second,"component.duplicate_type","One component of each type is supported per entity"); insert_id(value["id"]);
schemas.validate_component(value);
require(types.insert(value.at("type").get<std::string>()).second,
"component.duplicate_type",
"One component of each type is supported per entity");
} }
} }
for(const auto& [id,parent]:parents) { for (const auto& [id, parent] : parents) {
std::set<std::string> visited{id};auto current=parent; std::set<std::string> visited{id};
while(!current.empty()) {require(parents.contains(current),"entity.parent_missing","Parent entity is missing");require(visited.insert(current).second,"entity.cycle","Hierarchy contains a cycle");current=parents.at(current);} auto current = parent;
while (!current.empty()) {
require(parents.contains(current), "entity.parent_missing", "Parent entity is missing");
require(visited.insert(current).second, "entity.cycle", "Hierarchy contains a cycle");
current = parents.at(current);
}
} }
if(scene.contains("instances")) { if (scene.contains("instances")) {
require(scene["instances"].is_array(),"template.instances","Template instances must be an array"); require(scene["instances"].is_array(), "template.instances",
for(const auto& instance:scene["instances"]) { "Template instances must be an array");
require(instance.contains("id")&&instance.contains("source")&&instance["source"].is_string(),"template.instance","Invalid template instance"); for (const auto& instance : scene["instances"]) {
require(instance.contains("id") && instance.contains("source") &&
instance["source"].is_string(),
"template.instance", "Invalid template instance");
insert_id(instance["id"]); insert_id(instance["id"]);
} }
} }
} }
AuthoringService::AuthoringService(std::filesystem::path root,SchemaRegistry schemas):root_(std::filesystem::absolute(std::move(root)).lexically_normal()),schemas_(std::move(schemas)) {std::filesystem::create_directories(root_);} AuthoringService::AuthoringService(std::filesystem::path root, SchemaRegistry schemas)
AuthoringService::State& AuthoringService::state(const std::string& id) {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;} : root_(std::filesystem::absolute(std::move(root)).lexically_normal()),
const AuthoringService::State& AuthoringService::state(const std::string& id)const {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;} schemas_(std::move(schemas)) {
Json AuthoringService::summary(const State& value,bool include_data)const { std::filesystem::create_directories(root_);
Json result={{"id",value.data.at("id")},{"name",value.data.at("name")},{"revision",value.revision},{"dirty",sha256(value.data.dump())!=value.saved_hash},{"path",value.path.generic_string()},{"can_undo",!value.undo.empty()},{"can_redo",!value.redo.empty()}}; }
if(include_data) result["scene"]=value.data; AuthoringService::State& AuthoringService::state(const std::string& id) {
auto found = documents_.find(id);
require(found != documents_.end(), "document.missing", "Document is not open");
return found->second;
}
const AuthoringService::State& AuthoringService::state(const std::string& id) const {
auto found = documents_.find(id);
require(found != documents_.end(), "document.missing", "Document is not open");
return found->second;
}
Json AuthoringService::summary(const State& value, bool include_data) const {
Json result = {{"id", value.data.at("id")},
{"name", value.data.at("name")},
{"revision", value.revision},
{"dirty", sha256(value.data.dump()) != value.saved_hash},
{"path", value.path.generic_string()},
{"can_undo", !value.undo.empty()},
{"can_redo", !value.redo.empty()}};
if (include_data)
result["scene"] = value.data;
return result; return result;
} }
void AuthoringService::journal(const State& value)const { void AuthoringService::journal(const State& value) const {
atomic_write_json(project_path(root_,std::filesystem::path(".faset/recovery")/(value.data.at("id").get<std::string>()+".json")),{{"format","faset.recovery"},{"version",1},{"path",value.path.generic_string()},{"revision",value.revision},{"saved_hash",value.saved_hash},{"disk_hash",value.disk_hash},{"scene",value.data}}); atomic_write_json(project_path(root_, std::filesystem::path(".faset/recovery") /
(value.data.at("id").get<std::string>() + ".json")),
{{"format", "faset.recovery"},
{"version", 1},
{"path", value.path.generic_string()},
{"revision", value.revision},
{"saved_hash", value.saved_hash},
{"disk_hash", value.disk_hash},
{"scene", value.data}});
} }
Json AuthoringService::create(std::string name,int dimension) { Json AuthoringService::create(std::string name, int dimension) {
std::lock_guard lock(mutex_);State value;value.data=make_scene(std::move(name),dimension);journal(value); std::lock_guard lock(mutex_);
const auto id=value.data["id"].get<std::string>();documents_.emplace(id,std::move(value));return summary(state(id)); State value;
value.data = make_scene(std::move(name), dimension);
journal(value);
const auto id = value.data["id"].get<std::string>();
documents_.emplace(id, std::move(value));
return summary(state(id));
} }
Json AuthoringService::open(const std::filesystem::path& relative,bool recover) { Json AuthoringService::open(const std::filesystem::path& relative, bool recover) {
std::lock_guard lock(mutex_);auto path=project_path(root_,relative);Json data=read_json(path);validate_scene(data,schemas_); std::lock_guard lock(mutex_);
const auto id=data.at("id").get<std::string>(); auto path = project_path(root_, relative);
if(documents_.contains(id)) {require(state(id).path==relative.lexically_normal(),"document.id_collision","Another open file has the same document ID");return summary(state(id));} Json data = read_json(path);
State value;value.data=data;value.path=relative.lexically_normal();value.saved_hash=sha256(data.dump());value.disk_hash=sha256_file(path); validate_scene(data, schemas_);
const auto recovery=project_path(root_,std::filesystem::path(".faset/recovery")/(id+".json")); const auto id = data.at("id").get<std::string>();
if(recover&&std::filesystem::exists(recovery)) { if (documents_.contains(id)) {
const auto recovered=read_json(recovery); require(state(id).path == relative.lexically_normal(), "document.id_collision",
require(recovered.value("disk_hash",std::string())==value.disk_hash,"recovery.disk_conflict","Scene file changed since recovery was written"); "Another open file has the same document ID");
validate_scene(recovered.at("scene"),schemas_);value.data=recovered.at("scene");value.revision=recovered.value("revision",0u); return recover ? this->recover(id, state(id).revision) : summary(state(id));
} }
for(auto& item:value.data["entities"])for(auto& component:item["components"])component=schemas_.migrate_component(component); State value;
documents_.emplace(id,std::move(value));return summary(state(id)); value.data = data;
value.path = relative.lexically_normal();
value.saved_hash = sha256(data.dump());
value.disk_hash = sha256_file(path);
const auto recovery =
project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json"));
if (recover && std::filesystem::exists(recovery)) {
const auto recovered = read_json(recovery);
require(recovered.value("disk_hash", std::string()) == value.disk_hash,
"recovery.disk_conflict", "Scene file changed since recovery was written");
validate_scene(recovered.at("scene"), schemas_);
require(recovered.at("scene").at("id") == id, "recovery.id",
"Recovery ID does not match its document");
value.data = recovered.at("scene");
value.revision = recovered.value("revision", 0u);
}
for (auto& item : value.data["entities"])
for (auto& component : item["components"])
component = schemas_.migrate_component(component);
documents_.emplace(id, std::move(value));
return summary(state(id));
} }
Json AuthoringService::query(const std::string& id)const {std::lock_guard lock(mutex_);return summary(state(id));} Json AuthoringService::query(const std::string& id) const {
Json AuthoringService::documents()const {std::lock_guard lock(mutex_);Json result=Json::array();for(const auto& [id,value]:documents_)result.push_back(summary(value,false));return result;} std::lock_guard lock(mutex_);
void AuthoringService::register_schemas(const Json& manifest) {std::lock_guard lock(mutex_);schemas_.register_schemas(manifest);} return summary(state(id));
void AuthoringService::apply(Json& scene,const Json& command) { }
require(command.is_object()&&command.contains("op")&&command["op"].is_string(),"command.invalid","Command requires an operation name"); Json AuthoringService::documents() const {
const auto op=command.at("op").get<std::string>(); std::lock_guard lock(mutex_);
if(op=="entity.create") { Json result = Json::array();
Json value=command.contains("entity")&&command["entity"].is_object()?command["entity"]:make_entity(schemas_,command.value("name",std::string("Object")),command.value("parent",std::string())); for (const auto& [id, value] : documents_)
if(!value.contains("id")) value["id"]=new_id(); result.push_back(summary(value, false));
return result;
}
void AuthoringService::register_schemas(const Json& manifest) {
std::lock_guard lock(mutex_);
schemas_.register_schemas(manifest);
}
void AuthoringService::replace_external_schemas(const Json& manifest) {
std::lock_guard lock(mutex_);
auto candidate = builtin_schemas();
candidate.register_schemas(manifest);
schemas_ = std::move(candidate);
}
void AuthoringService::apply(Json& scene, const Json& command) {
require(command.is_object() && command.contains("op") && command["op"].is_string(),
"command.invalid", "Command requires an operation name");
const auto op = command.at("op").get<std::string>();
if (op == "entity.create") {
Json value = command.contains("entity") && command["entity"].is_object()
? command["entity"]
: make_entity(schemas_, command.value("name", std::string("Object")),
command.value("parent", std::string()));
if (!value.contains("id"))
value["id"] = new_id();
scene["entities"].push_back(std::move(value)); scene["entities"].push_back(std::move(value));
} else if(op=="entity.rename") { } else if (op == "entity.rename") {
entity(scene,command.at("entity").get<std::string>())["name"]=command.at("name"); entity(scene, command.at("entity").get<std::string>())["name"] = command.at("name");
} else if(op=="entity.delete") { } else if (op == "entity.delete") {
const auto id=command.at("entity").get<std::string>();entity(scene,id); const auto id = command.at("entity").get<std::string>();
std::set<std::string> removed{id};bool changed=true; entity(scene, id);
while(changed) {changed=false;for(const auto& item:scene["entities"])if(removed.contains(parent_id(item)))changed=removed.insert(item.at("id").get<std::string>()).second||changed;} std::set<std::string> removed{id};
auto& values=scene["entities"];values.erase(std::remove_if(values.begin(),values.end(),[&](const Json& value){return removed.contains(value.at("id").get<std::string>());}),values.end()); bool changed = true;
} else if(op=="entity.reparent") { while (changed) {
auto& value=entity(scene,command.at("entity").get<std::string>()); changed = false;
require(!command.value("keep_world",false),"transform.unsupported","World-preserving reparent requires the transform resolver"); for (const auto& item : scene["entities"])
const auto parent=command.value("parent",Json(nullptr));if(!parent.is_null())entity(scene,parent.get<std::string>());value["parent"]=parent; if (removed.contains(parent_id(item)))
} else if(op=="component.add") { changed = removed.insert(item.at("id").get<std::string>()).second || changed;
auto& value=entity(scene,command.at("entity").get<std::string>());const auto type=command.at("type").get<std::string>();
const auto metadata=schemas_.schema(type);Json fields=schemas_.default_fields(type);if(command.contains("fields"))fields.update(command["fields"]);
value["components"].push_back({{"id",command.value("id",new_id())},{"type",type},{"version",metadata.value("version",1)},{"fields",fields}});
} else if(op=="component.remove") {
auto& values=entity(scene,command.at("entity").get<std::string>())["components"];const auto id=command.at("component").get<std::string>();
auto found=std::find_if(values.begin(),values.end(),[&](const Json& value){return value.at("id")==id;});require(found!=values.end(),"component.missing","Component does not exist");values.erase(found);
} else if(op=="component.set") {
auto& value=component(entity(scene,command.at("entity").get<std::string>()),command.at("component").get<std::string>());
const auto field=command.at("field").get<std::string>();require(!field.empty(),"field.invalid","FieldId cannot be empty");value["fields"][field]=command.at("value");
} else if(op=="entity.duplicate") {
const auto id=command.at("entity").get<std::string>();entity(scene,id);
std::set<std::string> subtree{id};bool changed=true;
while(changed) {changed=false;for(const auto& item:scene["entities"])if(subtree.contains(parent_id(item)))changed=subtree.insert(item.at("id").get<std::string>()).second||changed;}
std::map<std::string,std::string> mapping;
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {mapping[item.at("id")]=new_id();for(const auto& component:item["components"])mapping[component.at("id")]=new_id();}
Json duplicates=Json::array();
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {
auto copy=item;copy["id"]=mapping.at(item.at("id").get<std::string>());const auto parent=parent_id(item);if(mapping.contains(parent))copy["parent"]=mapping.at(parent);
if(item.at("id")==id)copy["name"]=item.at("name").get<std::string>()+" Copy";
for(auto& component:copy["components"]) {
component["id"]=mapping.at(component.at("id").get<std::string>());const auto type=component.at("type").get<std::string>();
if(!schemas_.contains(type))continue;
const auto metadata=schemas_.schema(type);
for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&mapping.contains(value.get<std::string>()))value=mapping.at(value.get<std::string>());
}
duplicates.push_back(std::move(copy));
} }
for(auto& value:duplicates)scene["entities"].push_back(std::move(value)); auto& values = scene["entities"];
} else if(op=="scene.rename")scene["name"]=command.at("name"); values.erase(std::remove_if(values.begin(), values.end(),
else if(op=="template.instance") { [&](const Json& value) {
Json value=command.at("instance");if(!value.contains("id"))value["id"]=new_id();if(!scene.contains("instances"))scene["instances"]=Json::array();scene["instances"].push_back(std::move(value)); return removed.contains(value.at("id").get<std::string>());
} else if(op=="template.override"||op=="template.revert"||op=="template.suppress"||op=="template.add"||op=="template.reparent") { }),
auto& instances=scene["instances"];const auto id=command.at("instance").get<std::string>(); values.end());
auto found=std::find_if(instances.begin(),instances.end(),[&](const Json& value){return value.at("id")==id;});require(found!=instances.end(),"template.missing","Instance not found"); } else if (op == "entity.reparent") {
const std::string key=op=="template.suppress"?"suppressed":op=="template.add"?"additions":op=="template.reparent"?"reparents":"overrides"; reparent_entity(scene, command.at("entity").get<std::string>(),
if(!found->contains(key)) (*found)[key]=Json::array(); command.value("parent", Json(nullptr)), command.value("keep_world", false));
auto& records=(*found)[key]; } else if (op == "component.add") {
if(key=="overrides") { auto& value = entity(scene, command.at("entity").get<std::string>());
const auto address=command.at("address"); const auto type = command.at("type").get<std::string>();
auto old=std::find_if(records.begin(),records.end(),[&](const Json& value){return value.at("address")==address;}); const auto metadata = schemas_.schema(type);
if(old!=records.end())records.erase(old); Json fields = schemas_.default_fields(type);
if(op!="template.revert")records.push_back({{"address",address},{"value",command.at("value")}}); if (command.contains("fields"))
} else records.push_back(command.at("value")); fields.update(command["fields"]);
} else throw Error("command.unknown","Unknown authoring command: "+op); value["components"].push_back({{"id", command.value("id", new_id())},
{"type", type},
{"version", metadata.value("version", 1)},
{"fields", fields}});
} else if (op == "component.remove") {
auto& values = entity(scene, command.at("entity").get<std::string>())["components"];
const auto id = command.at("component").get<std::string>();
auto found = std::find_if(values.begin(), values.end(),
[&](const Json& value) { return value.at("id") == id; });
require(found != values.end(), "component.missing", "Component does not exist");
values.erase(found);
} else if (op == "component.set") {
auto& value = component(entity(scene, command.at("entity").get<std::string>()),
command.at("component").get<std::string>());
const auto field = command.at("field").get<std::string>();
require(!field.empty(), "field.invalid", "FieldId cannot be empty");
value["fields"][field] = command.at("value");
} else if (op == "entity.duplicate") {
const auto id = command.at("entity").get<std::string>();
entity(scene, id);
std::set<std::string> subtree{id};
bool changed = true;
while (changed) {
changed = false;
for (const auto& item : scene["entities"])
if (subtree.contains(parent_id(item)))
changed = subtree.insert(item.at("id").get<std::string>()).second || changed;
}
std::map<std::string, std::string> mapping;
for (const auto& item : scene["entities"])
if (subtree.contains(item.at("id").get<std::string>())) {
mapping[item.at("id")] = new_id();
for (const auto& component : item["components"])
mapping[component.at("id")] = new_id();
}
Json duplicates = Json::array();
for (const auto& item : scene["entities"])
if (subtree.contains(item.at("id").get<std::string>())) {
auto copy = item;
copy["id"] = mapping.at(item.at("id").get<std::string>());
const auto parent = parent_id(item);
if (mapping.contains(parent))
copy["parent"] = mapping.at(parent);
if (item.at("id") == id)
copy["name"] = item.at("name").get<std::string>() + " Copy";
for (auto& component : copy["components"]) {
component["id"] = mapping.at(component.at("id").get<std::string>());
const auto type = component.at("type").get<std::string>();
if (!schemas_.contains(type))
continue;
const auto metadata = schemas_.schema(type);
for (auto& [field, value] : component["fields"].items())
if (metadata["fields"].contains(field) &&
metadata["fields"][field].value("type", std::string()) ==
"entity_ref" &&
value.is_string() && mapping.contains(value.get<std::string>()))
value = mapping.at(value.get<std::string>());
}
duplicates.push_back(std::move(copy));
}
for (auto& value : duplicates)
scene["entities"].push_back(std::move(value));
} else if (op == "scene.rename")
scene["name"] = command.at("name");
else if (op == "template.instance") {
Json value = command.at("instance");
if (!value.contains("id"))
value["id"] = new_id();
if (!scene.contains("instances"))
scene["instances"] = Json::array();
scene["instances"].push_back(std::move(value));
} else if (op == "template.override" || op == "template.revert" || op == "template.suppress" ||
op == "template.add" || op == "template.reparent") {
auto& instances = scene["instances"];
const auto id = command.at("instance").get<std::string>();
auto found = std::find_if(instances.begin(), instances.end(),
[&](const Json& value) { return value.at("id") == id; });
require(found != instances.end(), "template.missing", "Instance not found");
const std::string key = op == "template.suppress" ? "suppressed"
: op == "template.add" ? "additions"
: op == "template.reparent" ? "reparents"
: "overrides";
if (!found->contains(key))
(*found)[key] = Json::array();
auto& records = (*found)[key];
if (key == "overrides") {
const auto address = command.at("address");
auto old = std::find_if(records.begin(), records.end(), [&](const Json& value) {
return value.at("address") == address;
});
if (old != records.end())
records.erase(old);
if (op != "template.revert")
records.push_back({{"address", address}, {"value", command.at("value")}});
} else
records.push_back(command.at("value"));
} else
throw Error("command.unknown", "Unknown authoring command: " + op);
} }
Json AuthoringService::transact(const std::string& id,std::uint64_t revision,const Json& operations,const std::string& key) { Json AuthoringService::transact(const std::string& id, std::uint64_t revision,
std::lock_guard lock(mutex_);auto& current=state(id);require(operations.is_array()&&!operations.empty(),"transaction.empty","Transaction requires an array of operations"); const Json& operations, const std::string& key) {
const auto fingerprint=sha256(Json{{"revision",revision},{"operations",operations}}.dump()); std::lock_guard lock(mutex_);
if(!key.empty()&&current.requests.contains(key)) { auto& current = state(id);
const auto& request=current.requests.at(key);require(request.first==fingerprint,"idempotency.conflict","Idempotency key was used with another payload");return request.second; require(operations.is_array() && !operations.empty(), "transaction.empty",
"Transaction requires an array of operations");
const auto fingerprint =
sha256(Json{{"revision", revision}, {"operations", operations}}.dump());
if (!key.empty() && current.requests.contains(key)) {
const auto& request = current.requests.at(key);
require(request.first == fingerprint, "idempotency.conflict",
"Idempotency key was used with another payload");
return request.second;
} }
check_revision(current.revision,revision);State candidate=current; check_revision(current.revision, revision);
for(const auto& operation:operations)apply(candidate.data,operation); State candidate = current;
validate_scene(candidate.data,schemas_); for (const auto& operation : operations)
candidate.undo.push_back(current.data);if(candidate.undo.size()>100)candidate.undo.erase(candidate.undo.begin());candidate.redo.clear();++candidate.revision; apply(candidate.data, operation);
journal(candidate);auto result=summary(candidate); validate_scene(candidate.data, schemas_);
if(!key.empty()) {if(candidate.requests.size()>=256)candidate.requests.erase(candidate.requests.begin());candidate.requests[key]={fingerprint,result};} candidate.undo.push_back(current.data);
current=std::move(candidate);return result; if (candidate.undo.size() > 100)
} candidate.undo.erase(candidate.undo.begin());
Json AuthoringService::history(const std::string& id,std::uint64_t revision,bool forward) { candidate.redo.clear();
std::lock_guard lock(mutex_);auto& current=state(id);check_revision(current.revision,revision);State candidate=current; ++candidate.revision;
auto& source=forward?candidate.redo:candidate.undo;auto& target=forward?candidate.undo:candidate.redo; journal(candidate);
require(!source.empty(),"history.empty",forward?"Nothing to redo":"Nothing to undo");target.push_back(candidate.data);candidate.data=source.back();source.pop_back();++candidate.revision;journal(candidate);current=std::move(candidate);return summary(current); auto result = summary(candidate);
} if (!key.empty()) {
Json AuthoringService::undo(const std::string& id,std::uint64_t revision){return history(id,revision,false);} if (candidate.requests.size() >= 256)
Json AuthoringService::redo(const std::string& id,std::uint64_t revision){return history(id,revision,true);} candidate.requests.erase(candidate.requests.begin());
Json AuthoringService::save(const std::string& id,const std::filesystem::path& relative) { candidate.requests[key] = {fingerprint, result};
std::lock_guard lock(mutex_);auto& current=state(id);const auto selected=relative.empty()?current.path:relative.lexically_normal();require(!selected.empty(),"save.path","Choose a scene path before saving");
const auto path=project_path(root_,selected);
if(std::filesystem::exists(path)) {
require(selected==current.path&&!current.disk_hash.empty(),"save.exists","Save As will not overwrite another file");
require(sha256_file(path)==current.disk_hash,"save.disk_conflict","File changed outside the Editor; reload or save to another path");
} }
atomic_write_json(path,current.data);current.path=selected;current.saved_hash=sha256(current.data.dump());current.disk_hash=sha256_file(path); current = std::move(candidate);
journal(current);return summary(current); return result;
} }
Json AuthoringService::recovery_documents()const { Json AuthoringService::history(const std::string& id, std::uint64_t revision, bool forward) {
std::lock_guard lock(mutex_);Json result=Json::array();const auto path=project_path(root_,".faset/recovery");if(!std::filesystem::exists(path))return result; std::lock_guard lock(mutex_);
for(const auto& entry:std::filesystem::directory_iterator(path))if(entry.is_regular_file()&&entry.path().extension()==".json") { auto& current = state(id);
try {const auto value=read_json(entry.path());result.push_back({{"id",value.at("scene").at("id")},{"name",value.at("scene").at("name")},{"path",value.at("path")},{"dirty",sha256(value.at("scene").dump())!=value.value("saved_hash",std::string())}});}catch(const std::exception&) {result.push_back({{"error","Invalid recovery record"},{"file",entry.path().filename().string()}});} check_revision(current.revision, revision);
}return result; State candidate = current;
auto& source = forward ? candidate.redo : candidate.undo;
auto& target = forward ? candidate.undo : candidate.redo;
require(!source.empty(), "history.empty", forward ? "Nothing to redo" : "Nothing to undo");
target.push_back(candidate.data);
candidate.data = source.back();
source.pop_back();
++candidate.revision;
journal(candidate);
current = std::move(candidate);
return summary(current);
} }
Json AuthoringService::undo(const std::string& id, std::uint64_t revision) {
return history(id, revision, false);
} }
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
return history(id, revision, true);
}
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) {
std::lock_guard lock(mutex_);
auto& current = state(id);
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
require(!selected.empty(), "save.path", "Choose a scene path before saving");
const auto path = project_path(root_, selected);
if (std::filesystem::exists(path)) {
require(selected == current.path && !current.disk_hash.empty(), "save.exists",
"Save As will not overwrite another file");
require(sha256_file(path) == current.disk_hash, "save.disk_conflict",
"File changed outside the Editor; reload or save to another path");
}
atomic_write_json(path, current.data);
current.path = selected;
current.saved_hash = sha256(current.data.dump());
current.disk_hash = sha256_file(path);
journal(current);
return summary(current);
}
Json AuthoringService::recover(const std::string& id,
std::optional<std::uint64_t> expected_revision) {
std::lock_guard lock(mutex_);
require(valid_id(Json(id)), "id.invalid", "Invalid recovery document ID");
const auto record =
read_json(project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json")));
require(record.value("format", std::string()) == "faset.recovery" &&
record.value("version", 0) == 1,
"recovery.format", "Unsupported recovery record");
State candidate;
candidate.data = record.at("scene");
validate_scene(candidate.data, schemas_);
require(candidate.data.at("id") == id, "recovery.id",
"Recovery ID does not match its document");
candidate.path = record.at("path").get<std::string>();
candidate.saved_hash = record.value("saved_hash", std::string());
candidate.disk_hash = record.value("disk_hash", std::string());
candidate.revision = record.value("revision", std::uint64_t(0));
if (!candidate.path.empty()) {
const auto disk = project_path(root_, candidate.path);
require(std::filesystem::exists(disk) && sha256_file(disk) == candidate.disk_hash,
"recovery.disk_conflict",
"Scene file changed or disappeared since recovery was written");
}
for (auto& item : candidate.data["entities"])
for (auto& component : item["components"])
component = schemas_.migrate_component(component);
validate_scene(candidate.data, schemas_);
if (documents_.contains(id)) {
const auto& current = state(id);
require(expected_revision.has_value(), "recovery.revision_required",
"Recovering an open document requires its current revision");
check_revision(current.revision, *expected_revision);
require(current.path == candidate.path, "document.id_collision",
"Recovery path differs from the open document");
if (candidate.data == current.data)
return summary(current);
candidate.undo = current.undo;
candidate.undo.push_back(current.data);
candidate.revision = std::max(current.revision, candidate.revision) + 1;
} else if (!candidate.path.empty())
candidate.undo.push_back(read_json(project_path(root_, candidate.path)));
journal(candidate);
documents_[id] = std::move(candidate);
return summary(state(id));
}
Json AuthoringService::recovery_documents() const {
std::lock_guard lock(mutex_);
Json result = Json::array();
const auto path = project_path(root_, ".faset/recovery");
if (!std::filesystem::exists(path))
return result;
for (const auto& entry : std::filesystem::directory_iterator(path))
if (entry.is_regular_file() && entry.path().extension() == ".json") {
try {
const auto value = read_json(entry.path());
result.push_back({{"id", value.at("scene").at("id")},
{"name", value.at("scene").at("name")},
{"path", value.at("path")},
{"dirty", sha256(value.at("scene").dump()) !=
value.value("saved_hash", std::string())}});
} catch (const std::exception&) {
result.push_back({{"error", "Invalid recovery record"},
{"file", entry.path().filename().string()}});
}
}
return result;
}
} // namespace faset::authoring
+169 -69
View File
@@ -1,99 +1,199 @@
#include <faset/authoring/templates.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <algorithm> #include <algorithm>
#include <faset/authoring/service.hpp>
#include <faset/authoring/templates.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/hash.hpp>
#include <set> #include <set>
namespace faset::authoring { namespace faset::authoring {
namespace { namespace {
std::string scoped_id(const std::string& root,const Json& path,const std::string& source) { std::string scoped_id(const std::string& root, const Json& path, const std::string& source) {
const auto digest=sha256(Json::array({root,path,source}).dump()); const auto digest = sha256(Json::array({root, path, source}).dump());
return digest.substr(0,8)+"-"+digest.substr(8,4)+"-5"+digest.substr(13,3)+"-a"+digest.substr(17,3)+"-"+digest.substr(20,12); return digest.substr(0, 8) + "-" + digest.substr(8, 4) + "-5" + digest.substr(13, 3) + "-a" +
digest.substr(17, 3) + "-" + digest.substr(20, 12);
} }
struct Resolver { struct Resolver {
const SchemaRegistry& schemas; const SchemaRegistry& schemas;
const SceneLoader& loader; const SceneLoader& loader;
std::string root; std::string root;
Json conflicts=Json::array(); Json conflicts = Json::array();
std::set<std::string> sources; std::set<std::string> sources;
void conflict(const Json& path,std::string code,const Json& record) {conflicts.push_back({{"instance_path",path},{"code",std::move(code)},{"record",record}});} void conflict(const Json& path, std::string code, const Json& record) {
Json* target(Json& entities,const Json& path,const Json& address) { conflicts.push_back(
Json full=path;for(const auto& entry:address.value("path",Json::array()))full.push_back(entry); {{"instance_path", path}, {"code", std::move(code)}, {"record", record}});
for(auto& item:entities)if(item.at("origin").at("path")==full&&item.at("origin").at("object")==address.at("object"))return &item; }
Json* target(Json& entities, const Json& path, const Json& address) {
Json full = path;
for (const auto& entry : address.value("path", Json::array()))
full.push_back(entry);
for (auto& item : entities)
if (item.at("origin").at("path") == full &&
item.at("origin").at("object") == address.at("object"))
return &item;
return nullptr; return nullptr;
} }
Json expand(const Json& scene,const Json& path) { Json expand(const Json& scene, const Json& path) {
require(path.size()<=32,"template.depth","Maximum template nesting depth exceeded"); require(path.size() <= 32, "template.depth", "Maximum template nesting depth exceeded");
validate_scene(scene,schemas); validate_scene(scene, schemas);
Json output=Json::array();std::map<std::string,std::string> ids; Json output = Json::array();
for(const auto& item:scene["entities"]) { std::map<std::string, std::string> ids;
const auto id=item.at("id").get<std::string>();ids[id]=path.empty()?id:scoped_id(root,path,id); for (const auto& item : scene["entities"]) {
for(const auto& component:item["components"]) {const auto cid=component.at("id").get<std::string>();ids[cid]=path.empty()?cid:scoped_id(root,path,cid);} const auto id = item.at("id").get<std::string>();
ids[id] = path.empty() ? id : scoped_id(root, path, id);
for (const auto& component : item["components"]) {
const auto cid = component.at("id").get<std::string>();
ids[cid] = path.empty() ? cid : scoped_id(root, path, cid);
}
} }
for(const auto& source:scene["entities"]) { for (const auto& source : scene["entities"]) {
Json item=source;item["id"]=ids.at(source.at("id").get<std::string>()); Json item = source;
item["origin"]={{"path",path},{"object",source.at("id")},{"scene",scene.at("id")}}; item["id"] = ids.at(source.at("id").get<std::string>());
if(source.contains("parent")&&!source["parent"].is_null())item["parent"]=ids.at(source["parent"].get<std::string>()); item["origin"] = {
for(auto& component:item["components"]) { {"path", path}, {"object", source.at("id")}, {"scene", scene.at("id")}};
const auto source_id=component.at("id").get<std::string>();component["id"]=ids.at(source_id);component["source_id"]=source_id; if (source.contains("parent") && !source["parent"].is_null())
const auto type=component.at("type").get<std::string>();if(!schemas.contains(type))continue; item["parent"] = ids.at(source["parent"].get<std::string>());
const auto metadata=schemas.schema(type); for (auto& component : item["components"]) {
for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&ids.contains(value.get<std::string>()))value=ids.at(value.get<std::string>()); const auto source_id = component.at("id").get<std::string>();
component["id"] = ids.at(source_id);
component["source_id"] = source_id;
const auto type = component.at("type").get<std::string>();
if (!schemas.contains(type))
continue;
const auto metadata = schemas.schema(type);
for (auto& [field, value] : component["fields"].items())
if (metadata["fields"].contains(field) &&
metadata["fields"][field].value("type", std::string()) == "entity_ref" &&
value.is_string() && ids.contains(value.get<std::string>()))
value = ids.at(value.get<std::string>());
} }
output.push_back(std::move(item)); output.push_back(std::move(item));
} }
for(const auto& instance:scene.value("instances",Json::array())) { for (const auto& instance : scene.value("instances", Json::array())) {
Json nested_path=path;nested_path.push_back(instance.at("id"));const auto source_name=instance.at("source").get<std::string>(); Json nested_path = path;
Json expanded=Json::array();std::string source_id; nested_path.push_back(instance.at("id"));
const auto source_name = instance.at("source").get<std::string>();
Json expanded = Json::array();
std::string source_id;
bool inserted = false;
try { try {
const auto source=loader(source_name);source_id=source.at("id").get<std::string>(); const auto source = loader(source_name);
require(sources.insert(source_id).second,"template.cycle","Template source cycle detected"); source_id = source.at("id").get<std::string>();
expanded=expand(source,nested_path);sources.erase(source_id); inserted = sources.insert(source_id).second;
} catch(const std::exception& error) { require(inserted, "template.cycle", "Template source cycle detected");
if(!source_id.empty())sources.erase(source_id); expanded = expand(source, nested_path);
conflict(nested_path,"template.source_unavailable",{{"source",source_name},{"message",error.what()}});continue; sources.erase(source_id);
} catch (const std::exception& error) {
if (inserted)
sources.erase(source_id);
conflict(nested_path, "template.source_unavailable",
{{"source", source_name}, {"message", error.what()}});
continue;
} }
for(const auto& addition:instance.value("additions",Json::array())) { for (const auto& addition : instance.value("additions", Json::array())) {
Json item=addition;const auto id=item.at("id").get<std::string>();item["id"]=scoped_id(root,nested_path,id); Json item = addition;
item["origin"]={{"path",nested_path},{"object",id},{"local",true}}; const auto id = item.at("id").get<std::string>();
if(item.contains("parent")&&!item["parent"].is_null())item["parent"]=scoped_id(root,nested_path,item["parent"].get<std::string>()); item["id"] = scoped_id(root, nested_path, id);
for(auto& component:item["components"]) {const auto cid=component.at("id").get<std::string>();component["source_id"]=cid;component["id"]=scoped_id(root,nested_path,cid);} item["origin"] = {{"path", nested_path}, {"object", id}, {"local", true}};
if (item.contains("parent") && !item["parent"].is_null())
item["parent"] =
scoped_id(root, nested_path, item["parent"].get<std::string>());
for (auto& component : item["components"]) {
const auto cid = component.at("id").get<std::string>();
component["source_id"] = cid;
component["id"] = scoped_id(root, nested_path, cid);
}
expanded.push_back(std::move(item)); expanded.push_back(std::move(item));
} }
for(const auto& change:instance.value("overrides",Json::array())) { for (const auto& change : instance.value("overrides", Json::array())) {
const auto& address=change.at("address");auto* item=target(expanded,nested_path,address); const auto& address = change.at("address");
if(!item){conflict(nested_path,"override.object_missing",change);continue;} auto* item = target(expanded, nested_path, address);
auto found=std::find_if((*item)["components"].begin(),(*item)["components"].end(),[&](const Json& value){return value.at("source_id")==address.at("component");}); if (!item) {
if(found==(*item)["components"].end()){conflict(nested_path,"override.component_missing",change);continue;} conflict(nested_path, "override.object_missing", change);
const auto type=found->at("type").get<std::string>();const auto field=address.at("field").get<std::string>(); continue;
if(!schemas.contains(type)||!schemas.schema(type)["fields"].contains(field)){conflict(nested_path,"override.field_unavailable",change);continue;} }
try {validate_field(change.at("value"),schemas.schema(type)["fields"][field]);(*found)["fields"][field]=change.at("value");} auto found =
catch(const std::exception& error){conflict(nested_path,"override.invalid",{{"change",change},{"message",error.what()}});} std::find_if((*item)["components"].begin(), (*item)["components"].end(),
[&](const Json& value) {
return value.at("source_id") == address.at("component");
});
if (found == (*item)["components"].end()) {
conflict(nested_path, "override.component_missing", change);
continue;
}
const auto type = found->at("type").get<std::string>();
const auto field = address.at("field").get<std::string>();
if (!schemas.contains(type) || !schemas.schema(type)["fields"].contains(field)) {
conflict(nested_path, "override.field_unavailable", change);
continue;
}
try {
validate_field(change.at("value"), schemas.schema(type)["fields"][field]);
(*found)["fields"][field] = change.at("value");
} catch (const std::exception& error) {
conflict(nested_path, "override.invalid",
{{"change", change}, {"message", error.what()}});
}
} }
std::set<std::string> suppressed; std::set<std::string> suppressed;
for(const auto& address:instance.value("suppressed",Json::array())) { for (const auto& address : instance.value("suppressed", Json::array())) {
auto* item=target(expanded,nested_path,address);if(item)suppressed.insert(item->at("id").get<std::string>());else conflict(nested_path,"suppression.object_missing",address); auto* item = target(expanded, nested_path, address);
if (item)
suppressed.insert(item->at("id").get<std::string>());
else
conflict(nested_path, "suppression.object_missing", address);
} }
bool changed=true; bool changed = true;
while(changed) {changed=false;for(const auto& item:expanded)if(item.contains("parent")&&item["parent"].is_string()&&suppressed.contains(item["parent"].get<std::string>()))changed=suppressed.insert(item.at("id").get<std::string>()).second||changed;} while (changed) {
expanded.erase(std::remove_if(expanded.begin(),expanded.end(),[&](const Json& item){return suppressed.contains(item.at("id").get<std::string>());}),expanded.end()); changed = false;
for(const auto& reparent:instance.value("reparents",Json::array())) { for (const auto& item : expanded)
auto* item=target(expanded,nested_path,reparent.at("object")); if (item.contains("parent") && item["parent"].is_string() &&
auto* parent=reparent.at("parent").is_null()?nullptr:target(expanded,nested_path,reparent.at("parent")); suppressed.contains(item["parent"].get<std::string>()))
if(!item||(!reparent.at("parent").is_null()&&!parent)){conflict(nested_path,"reparent.target_missing",reparent);continue;} changed =
if(reparent.value("keep_world",false)){conflict(nested_path,"reparent.world_transform_required",reparent);continue;} suppressed.insert(item.at("id").get<std::string>()).second || changed;
(*item)["parent"]=parent?parent->at("id"):Json(nullptr);
} }
for(auto& item:expanded)output.push_back(std::move(item)); expanded.erase(std::remove_if(expanded.begin(), expanded.end(),
require(output.size()<=100000,"template.size","Resolved scene exceeds object limit"); [&](const Json& item) {
return suppressed.contains(
item.at("id").get<std::string>());
}),
expanded.end());
for (const auto& reparent : instance.value("reparents", Json::array())) {
auto* item = target(expanded, nested_path, reparent.at("object"));
auto* parent = reparent.at("parent").is_null()
? nullptr
: target(expanded, nested_path, reparent.at("parent"));
if (!item || (!reparent.at("parent").is_null() && !parent)) {
conflict(nested_path, "reparent.target_missing", reparent);
continue;
}
const auto id = item->at("id").get<std::string>();
const Json parent_id = parent ? parent->at("id") : Json(nullptr);
Json temporary = {{"entities", expanded}};
try {
reparent_entity(temporary, id, parent_id, reparent.value("keep_world", false));
expanded = temporary["entities"];
} catch (const std::exception& error) {
conflict(nested_path, "reparent.transform_conflict",
{{"record", reparent}, {"message", error.what()}});
}
}
for (auto& item : expanded)
output.push_back(std::move(item));
require(output.size() <= 100000, "template.size",
"Resolved scene exceeds object limit");
} }
return output; return output;
} }
}; };
} // namespace
ResolvedScene resolve_templates(const Json& scene, const SchemaRegistry& schemas,
const SceneLoader& loader) {
Resolver resolver{schemas, loader, scene.at("id").get<std::string>()};
resolver.sources.insert(scene.at("id").get<std::string>());
Json output = scene;
output["entities"] = resolver.expand(scene, Json::array());
output["instances"] = Json::array();
validate_scene(output, schemas);
return {output, resolver.conflicts};
} }
ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader) { } // namespace faset::authoring
Resolver resolver{schemas,loader,scene.at("id").get<std::string>()};resolver.sources.insert(scene.at("id").get<std::string>());
Json output=scene;output["entities"]=resolver.expand(scene,Json::array());output["instances"]=Json::array();
validate_scene(output,schemas);return {output,resolver.conflicts};
}
}
+156
View File
@@ -0,0 +1,156 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <faset/authoring/transforms.hpp>
#include <faset/core/error.hpp>
#include <set>
namespace faset::authoring {
namespace {
using Matrix = std::array<double, 16>;
constexpr Matrix identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
Json& find_entity(Json& scene, const std::string& id) {
for (auto& item : scene["entities"])
if (item.at("id") == id)
return item;
throw Error("entity.missing", "Entity does not exist: " + id);
}
Json* transform(Json& entity) {
for (auto& component : entity["components"])
if (component.at("type") == "faset.transform")
return &component;
return nullptr;
}
Matrix multiply(const Matrix& a, const Matrix& b) {
Matrix result{};
for (int row = 0; row < 4; ++row)
for (int column = 0; column < 4; ++column)
for (int k = 0; k < 4; ++k)
result[column * 4 + row] += a[k * 4 + row] * b[column * 4 + k];
return result;
}
Matrix local(Json& entity) {
const auto* component = transform(entity);
if (!component)
return identity;
const auto& fields = component->at("fields");
const auto p = fields.value("position", std::array<double, 3>{0, 0, 0}),
r = fields.value("rotation", std::array<double, 3>{0, 0, 0}),
s = fields.value("scale", std::array<double, 3>{1, 1, 1});
const auto cx = std::cos(r[0]), sx = std::sin(r[0]), cy = std::cos(r[1]), sy = std::sin(r[1]),
cz = std::cos(r[2]), sz = std::sin(r[2]);
return {cz * cy * s[0],
sz * cy * s[0],
-sy * s[0],
0,
(cz * sy * sx - sz * cx) * s[1],
(sz * sy * sx + cz * cx) * s[1],
cy * sx * s[1],
0,
(cz * sy * cx + sz * sx) * s[2],
(sz * sy * cx - cz * sx) * s[2],
cy * cx * s[2],
0,
p[0],
p[1],
p[2],
1};
}
Matrix world(Json& scene, const std::string& id, std::set<std::string>& visited) {
require(visited.insert(id).second, "entity.cycle", "Hierarchy contains a cycle");
auto& item = find_entity(scene, id);
auto result = local(item);
if (item.contains("parent") && !item["parent"].is_null())
result = multiply(world(scene, item["parent"].get<std::string>(), visited), result);
return result;
}
Matrix world(Json& scene, const std::string& id) {
std::set<std::string> visited;
return world(scene, id, visited);
}
Matrix inverse(const Matrix& matrix) {
std::array<std::array<double, 8>, 4> rows{};
for (int r = 0; r < 4; ++r) {
for (int c = 0; c < 4; ++c)
rows[r][c] = matrix[c * 4 + r];
rows[r][r + 4] = 1;
}
for (int column = 0; column < 4; ++column) {
int pivot = column;
for (int row = column + 1; row < 4; ++row)
if (std::abs(rows[row][column]) > std::abs(rows[pivot][column]))
pivot = row;
require(std::abs(rows[pivot][column]) > 1e-12, "transform.singular",
"Cannot preserve world transform under a non-invertible parent");
std::swap(rows[pivot], rows[column]);
const auto scale = rows[column][column];
for (auto& entry : rows[column])
entry /= scale;
for (int row = 0; row < 4; ++row)
if (row != column) {
const auto factor = rows[row][column];
for (int c = 0; c < 8; ++c)
rows[row][c] -= factor * rows[column][c];
}
}
Matrix result{};
for (int row = 0; row < 4; ++row)
for (int column = 0; column < 4; ++column)
result[column * 4 + row] = rows[row][column + 4];
return result;
}
Json decompose(const Matrix& matrix) {
std::array<double, 3> scale{};
Matrix rotation = matrix;
for (int c = 0; c < 3; ++c) {
scale[c] = std::sqrt(matrix[c * 4] * matrix[c * 4] + matrix[c * 4 + 1] * matrix[c * 4 + 1] +
matrix[c * 4 + 2] * matrix[c * 4 + 2]);
require(scale[c] > 1e-12, "transform.singular", "Cannot decompose zero scale");
}
const double determinant = matrix[0] * (matrix[5] * matrix[10] - matrix[9] * matrix[6]) -
matrix[4] * (matrix[1] * matrix[10] - matrix[9] * matrix[2]) +
matrix[8] * (matrix[1] * matrix[6] - matrix[5] * matrix[2]);
if (determinant < 0)
scale[0] = -scale[0];
for (int c = 0; c < 3; ++c)
for (int r = 0; r < 3; ++r)
rotation[c * 4 + r] /= scale[c];
for (int a = 0; a < 3; ++a)
for (int b = a + 1; b < 3; ++b) {
double dot = 0;
for (int r = 0; r < 3; ++r)
dot += rotation[a * 4 + r] * rotation[b * 4 + r];
require(std::abs(dot) < 1e-5, "transform.shear",
"Preserving this world transform would require shear; choose keep local or "
"change parent scale");
}
std::array<double, 3> angles{};
angles[1] = std::asin(std::clamp(-rotation[2], -1.0, 1.0));
if (std::abs(std::cos(angles[1])) > 1e-7) {
angles[0] = std::atan2(rotation[6], rotation[10]);
angles[2] = std::atan2(rotation[1], rotation[0]);
} else {
angles[0] = std::atan2(-rotation[9], rotation[5]);
angles[2] = 0;
}
return {
{"position", {matrix[12], matrix[13], matrix[14]}}, {"rotation", angles}, {"scale", scale}};
}
} // namespace
void reparent_entity(Json& scene, const std::string& id, const Json& parent, bool keep_world) {
auto& item = find_entity(scene, id);
if (!parent.is_null())
find_entity(scene, parent.get<std::string>());
if (keep_world) {
auto* component = transform(item);
require(component != nullptr, "transform.required",
"World-preserving reparent requires a Transform component");
const auto previous = world(scene, id);
const auto basis = parent.is_null() ? identity : world(scene, parent.get<std::string>());
const auto fields = decompose(multiply(inverse(basis), previous));
for (const auto& [field, value] : fields.items())
(*component)["fields"][field] = value;
}
item["parent"] = parent;
}
} // namespace faset::authoring
+92 -48
View File
@@ -1,72 +1,116 @@
#include <faset/core/hash.hpp>
#include <faset/core/error.hpp>
#include <array> #include <array>
#include <bit> #include <bit>
#include <cstdint> #include <cstdint>
#include <faset/core/error.hpp>
#include <faset/core/hash.hpp>
#include <fstream> #include <fstream>
#include <vector> #include <vector>
namespace faset { namespace faset {
namespace { namespace {
constexpr std::array<std::uint32_t,64> constants = { constexpr std::array<std::uint32_t, 64> constants = {
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
};
class Digest { class Digest {
std::array<std::uint32_t,8> state_{0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19}; std::array<std::uint32_t, 8> state_{0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
std::array<std::uint8_t,64> pending_{}; 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
std::uint64_t count_=0; std::array<std::uint8_t, 64> pending_{};
std::size_t used_=0; std::uint64_t count_ = 0;
std::size_t used_ = 0;
void block() { void block() {
std::array<std::uint32_t,64> w{}; std::array<std::uint32_t, 64> w{};
for (int i=0;i<16;++i) w[i]=(std::uint32_t(pending_[i*4])<<24)|(std::uint32_t(pending_[i*4+1])<<16)|(std::uint32_t(pending_[i*4+2])<<8)|pending_[i*4+3]; for (int i = 0; i < 16; ++i)
for (int i=16;i<64;++i) { w[i] = (std::uint32_t(pending_[i * 4]) << 24) |
const auto x=w[i-15],y=w[i-2]; (std::uint32_t(pending_[i * 4 + 1]) << 16) |
w[i]=w[i-16]+(std::rotr(x,7)^std::rotr(x,18)^(x>>3))+w[i-7]+(std::rotr(y,17)^std::rotr(y,19)^(y>>10)); (std::uint32_t(pending_[i * 4 + 2]) << 8) | pending_[i * 4 + 3];
for (int i = 16; i < 64; ++i) {
const auto x = w[i - 15], y = w[i - 2];
w[i] = w[i - 16] + (std::rotr(x, 7) ^ std::rotr(x, 18) ^ (x >> 3)) + w[i - 7] +
(std::rotr(y, 17) ^ std::rotr(y, 19) ^ (y >> 10));
} }
auto a=state_[0],b=state_[1],c=state_[2],d=state_[3],e=state_[4],f=state_[5],g=state_[6],h=state_[7]; auto a = state_[0], b = state_[1], c = state_[2], d = state_[3], e = state_[4],
for (int i=0;i<64;++i) { f = state_[5], g = state_[6], h = state_[7];
const auto t1=h+(std::rotr(e,6)^std::rotr(e,11)^std::rotr(e,25))+((e&f)^(~e&g))+constants[i]+w[i]; for (int i = 0; i < 64; ++i) {
const auto t2=(std::rotr(a,2)^std::rotr(a,13)^std::rotr(a,22))+((a&b)^(a&c)^(b&c)); const auto t1 = h + (std::rotr(e, 6) ^ std::rotr(e, 11) ^ std::rotr(e, 25)) +
h=g;g=f;f=e;e=d+t1;d=c;c=b;b=a;a=t1+t2; ((e & f) ^ (~e & g)) + constants[i] + w[i];
const auto t2 = (std::rotr(a, 2) ^ std::rotr(a, 13) ^ std::rotr(a, 22)) +
((a & b) ^ (a & c) ^ (b & c));
h = g;
g = f;
f = e;
e = d + t1;
d = c;
c = b;
b = a;
a = t1 + t2;
} }
state_[0]+=a;state_[1]+=b;state_[2]+=c;state_[3]+=d;state_[4]+=e;state_[5]+=f;state_[6]+=g;state_[7]+=h; state_[0] += a;
state_[1] += b;
state_[2] += c;
state_[3] += d;
state_[4] += e;
state_[5] += f;
state_[6] += g;
state_[7] += h;
} }
public:
public:
void update(std::span<const std::byte> bytes) { void update(std::span<const std::byte> bytes) {
count_+=bytes.size(); count_ += bytes.size();
for (auto byte:bytes) { for (auto byte : bytes) {
pending_[used_++]=std::to_integer<std::uint8_t>(byte); pending_[used_++] = std::to_integer<std::uint8_t>(byte);
if (used_==64) { block();used_=0; } if (used_ == 64) {
block();
used_ = 0;
}
} }
} }
std::string finish() { std::string finish() {
const std::uint64_t bits=count_*8; const std::uint64_t bits = count_ * 8;
pending_[used_++]=0x80; pending_[used_++] = 0x80;
if (used_>56) { while(used_<64) pending_[used_++]=0;block();used_=0; } if (used_ > 56) {
while(used_<56) pending_[used_++]=0; while (used_ < 64)
for (int i=7;i>=0;--i) pending_[used_++]=std::uint8_t(bits>>(i*8)); pending_[used_++] = 0;
block();
used_ = 0;
}
while (used_ < 56)
pending_[used_++] = 0;
for (int i = 7; i >= 0; --i)
pending_[used_++] = std::uint8_t(bits >> (i * 8));
block(); block();
constexpr char hex[]="0123456789abcdef"; constexpr char hex[] = "0123456789abcdef";
std::string result;result.reserve(64); std::string result;
for (auto word:state_) for (int i=7;i>=0;--i) result+=hex[(word>>(i*4))&15]; result.reserve(64);
for (auto word : state_)
for (int i = 7; i >= 0; --i)
result += hex[(word >> (i * 4)) & 15];
return result; return result;
} }
}; };
} } // namespace
std::string sha256(std::span<const std::byte> bytes) { Digest digest;digest.update(bytes);return digest.finish(); } std::string sha256(std::span<const std::byte> bytes) {
std::string sha256_file(const std::filesystem::path& path) { Digest digest;
std::ifstream stream(path,std::ios::binary); digest.update(bytes);
require(bool(stream),"io.open","Cannot open file for hashing: "+path.string());
Digest digest;std::array<char,65536> buffer{};
while(stream) { stream.read(buffer.data(),buffer.size());digest.update(std::as_bytes(std::span(buffer.data(),static_cast<std::size_t>(stream.gcount())))); }
require(stream.eof(),"io.read","Cannot read file for hashing: "+path.string());
return digest.finish(); return digest.finish();
} }
std::string sha256_file(const std::filesystem::path& path) {
std::ifstream stream(path, std::ios::binary);
require(bool(stream), "io.open", "Cannot open file for hashing: " + path.string());
Digest digest;
std::array<char, 65536> buffer{};
while (stream) {
stream.read(buffer.data(), buffer.size());
digest.update(
std::as_bytes(std::span(buffer.data(), static_cast<std::size_t>(stream.gcount()))));
}
require(stream.eof(), "io.read", "Cannot read file for hashing: " + path.string());
return digest.finish();
} }
} // namespace faset
+97 -43
View File
@@ -1,9 +1,9 @@
#include <faset/core/io.hpp>
#include <faset/core/error.hpp>
#include <array> #include <array>
#include <faset/core/error.hpp>
#include <faset/core/io.hpp>
#include <fstream> #include <fstream>
#include <random>
#include <mutex> #include <mutex>
#include <random>
#ifdef _WIN32 #ifdef _WIN32
#define NOMINMAX #define NOMINMAX
#include <windows.h> #include <windows.h>
@@ -16,57 +16,111 @@ namespace faset {
std::string new_id() { std::string new_id() {
static std::mutex mutex; static std::mutex mutex;
static std::random_device random; static std::random_device random;
std::array<unsigned char,16> bytes{}; std::array<unsigned char, 16> bytes{};
{ std::lock_guard lock(mutex); for(auto& byte:bytes) byte=static_cast<unsigned char>(random()); } {
bytes[6]=(bytes[6]&0x0f)|0x40;bytes[8]=(bytes[8]&0x3f)|0x80; std::lock_guard lock(mutex);
constexpr char hex[]="0123456789abcdef"; for (auto& byte : bytes)
std::string result;result.reserve(36); byte = static_cast<unsigned char>(random());
for(std::size_t i=0;i<bytes.size();++i) { if(i==4||i==6||i==8||i==10)result+='-';result+=hex[bytes[i]>>4];result+=hex[bytes[i]&15]; } }
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
constexpr char hex[] = "0123456789abcdef";
std::string result;
result.reserve(36);
for (std::size_t i = 0; i < bytes.size(); ++i) {
if (i == 4 || i == 6 || i == 8 || i == 10)
result += '-';
result += hex[bytes[i] >> 4];
result += hex[bytes[i] & 15];
}
return result; return result;
} }
std::string read_text(const std::filesystem::path& path) { std::string read_text(const std::filesystem::path& path) {
std::ifstream stream(path,std::ios::binary); std::ifstream stream(path, std::ios::binary);
require(bool(stream),"io.open","Cannot open file: "+path.string()); require(bool(stream), "io.open", "Cannot open file: " + path.string());
std::string value((std::istreambuf_iterator<char>(stream)),{}); std::string value((std::istreambuf_iterator<char>(stream)), {});
require(!stream.bad(),"io.read","Cannot read file: "+path.string());return value; require(!stream.bad(), "io.read", "Cannot read file: " + path.string());
return value;
} }
Json read_json(const std::filesystem::path& path) { Json read_json(const std::filesystem::path& path) {
try { return Json::parse(read_text(path)); } try {
catch(const Json::exception& error) { throw Error("format.json","Invalid JSON in "+path.string(),{{"reason",error.what()}}); } return Json::parse(read_text(path));
} catch (const Json::exception& error) {
throw Error("format.json", "Invalid JSON in " + path.string(), {{"reason", error.what()}});
}
} }
void atomic_write(const std::filesystem::path& path,std::string_view bytes) { void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
const auto parent=path.has_parent_path()?path.parent_path():std::filesystem::path("."); const auto parent = path.has_parent_path() ? path.parent_path() : std::filesystem::path(".");
std::filesystem::create_directories(parent); std::filesystem::create_directories(parent);
const auto temporary=parent/(path.filename().string()+".tmp-"+new_id()); const auto temporary = parent / (path.filename().string() + ".tmp-" + new_id());
try { try {
#ifdef _WIN32 #ifdef _WIN32
HANDLE file=CreateFileW(temporary.c_str(),GENERIC_WRITE,0,nullptr,CREATE_NEW,FILE_ATTRIBUTE_NORMAL,nullptr); HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW,
require(file!=INVALID_HANDLE_VALUE,"io.create","Cannot create temporary file"); FILE_ATTRIBUTE_NORMAL, nullptr);
bool ok=true;std::size_t offset=0; require(file != INVALID_HANDLE_VALUE, "io.create", "Cannot create temporary file");
while(offset<bytes.size()) { DWORD written=0; const auto count=static_cast<DWORD>(std::min<std::size_t>(bytes.size()-offset,1u<<30)); if(!WriteFile(file,bytes.data()+offset,count,&written,nullptr)||written==0){ok=false;break;} offset+=written; } bool ok = true;
ok=FlushFileBuffers(file)&&ok;CloseHandle(file); std::size_t offset = 0;
require(ok,"io.write","Cannot flush temporary file"); while (offset < bytes.size()) {
require(MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)!=0,"io.replace","Cannot publish file: "+path.string()); DWORD written = 0;
const auto count =
static_cast<DWORD>(std::min<std::size_t>(bytes.size() - offset, 1u << 30));
if (!WriteFile(file, bytes.data() + offset, count, &written, nullptr) || written == 0) {
ok = false;
break;
}
offset += written;
}
ok = FlushFileBuffers(file) && ok;
CloseHandle(file);
require(ok, "io.write", "Cannot flush temporary file");
require(MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0,
"io.replace", "Cannot publish file: " + path.string());
#else #else
const int fd=::open(temporary.c_str(),O_WRONLY|O_CREAT|O_EXCL,0644); const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
require(fd>=0,"io.create","Cannot create temporary file"); require(fd >= 0, "io.create", "Cannot create temporary file");
bool ok=true;std::size_t offset=0; bool ok = true;
while(offset<bytes.size()) { const auto count=::write(fd,bytes.data()+offset,bytes.size()-offset);if(count<0&&errno==EINTR)continue;if(count<=0){ok=false;break;}offset+=static_cast<std::size_t>(count); } std::size_t offset = 0;
ok=(::fsync(fd)==0)&&ok;const auto closed=::close(fd);ok=ok&&(closed==0); while (offset < bytes.size()) {
require(ok,"io.write","Cannot flush temporary file"); const auto count = ::write(fd, bytes.data() + offset, bytes.size() - offset);
std::filesystem::rename(temporary,path); if (count < 0 && errno == EINTR)
const int directory=::open(parent.c_str(),O_RDONLY|O_DIRECTORY); continue;
if(directory>=0){::fsync(directory);::close(directory);} if (count <= 0) {
ok = false;
break;
}
offset += static_cast<std::size_t>(count);
}
ok = (::fsync(fd) == 0) && ok;
const auto closed = ::close(fd);
ok = ok && (closed == 0);
require(ok, "io.write", "Cannot flush temporary file");
std::filesystem::rename(temporary, path);
const int directory = ::open(parent.c_str(), O_RDONLY | O_DIRECTORY);
if (directory >= 0) {
::fsync(directory);
::close(directory);
}
#endif #endif
} catch(...) { std::error_code ignored;std::filesystem::remove(temporary,ignored);throw; } } catch (...) {
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
throw;
}
} }
void atomic_write_json(const std::filesystem::path& path,const Json& value) { atomic_write(path,value.dump(2)+"\n"); } void atomic_write_json(const std::filesystem::path& path, const Json& value) {
std::filesystem::path project_path(const std::filesystem::path& root,const std::filesystem::path& relative) { atomic_write(path, value.dump(2) + "\n");
require(!relative.is_absolute(),"path.outside_project","Expected a path relative to the project"); }
const auto canonical=std::filesystem::weakly_canonical(root); std::filesystem::path project_path(const std::filesystem::path& root,
const auto target=std::filesystem::weakly_canonical(canonical/relative); const std::filesystem::path& relative) {
auto a=canonical.begin(),b=target.begin(); require(!relative.is_absolute(), "path.outside_project",
for(;a!=canonical.end();++a,++b) require(b!=target.end()&&*a==*b,"path.outside_project","Path escapes the project root"); "Expected a path relative to the project");
const auto canonical = std::filesystem::weakly_canonical(root);
const auto target = std::filesystem::weakly_canonical(canonical / relative);
auto a = canonical.begin(), b = target.begin();
for (; a != canonical.end(); ++a, ++b)
require(b != target.end() && *a == *b, "path.outside_project",
"Path escapes the project root");
return target; return target;
} }
} } // namespace faset
+427
View File
@@ -0,0 +1,427 @@
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdlib>
#include <cstring>
#include <faset/core/process.hpp>
#include <stdexcept>
#include <thread>
#include <utility>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <cerrno>
#include <csignal>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
extern char** environ;
#endif
namespace faset {
namespace {
#ifdef _WIN32
std::wstring widen(const std::string& value) {
if (value.empty())
return {};
int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0);
if (!length)
throw std::runtime_error("Invalid UTF-8 process argument");
std::wstring result(length, 0);
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast<int>(value.size()),
result.data(), length);
return result;
}
std::wstring quote(const std::wstring& value) {
std::wstring result = L"\"";
std::size_t slashes{};
for (wchar_t c : value) {
if (c == L'\\') {
++slashes;
continue;
}
if (c == L'\"') {
result.append(slashes * 2 + 1, L'\\');
result += c;
} else {
result.append(slashes, L'\\');
result += c;
}
slashes = 0;
}
result.append(slashes * 2, L'\\');
result += L'\"';
return result;
}
struct CaseInsensitive {
bool operator()(const std::wstring& a, const std::wstring& b) const {
return _wcsicmp(a.c_str(), b.c_str()) < 0;
}
};
#else
std::filesystem::path resolve_program(const std::string& name, const std::string& path,
const std::filesystem::path& cwd) {
if (name.find('/') != std::string::npos) {
auto file = std::filesystem::path(name);
if (file.is_relative())
file = cwd / file;
if (::access(file.c_str(), X_OK) == 0 && !std::filesystem::is_directory(file))
return std::filesystem::absolute(file);
throw std::runtime_error("Executable is missing or not executable: " + name);
}
std::size_t from{};
do {
auto end = path.find(':', from);
auto directory = path.substr(from, end == std::string::npos ? end : end - from);
auto file = (directory.empty() ? cwd : std::filesystem::path(directory)) / name;
if (file.is_relative())
file = cwd / file;
if (::access(file.c_str(), X_OK) == 0 && !std::filesystem::is_directory(file))
return std::filesystem::absolute(file);
if (end == std::string::npos)
break;
from = end + 1;
} while (true);
throw std::runtime_error("Executable not found on PATH: " + name);
}
#endif
} // namespace
std::filesystem::path find_executable(const std::string& name) {
#ifdef _WIN32
auto wide = widen(name);
std::vector<wchar_t> buffer(32768);
DWORD length = SearchPathW(nullptr, wide.c_str(), L".exe", static_cast<DWORD>(buffer.size()),
buffer.data(), nullptr);
if (length == 0 || length >= buffer.size())
throw std::runtime_error("Executable not found on PATH: " + name);
return std::filesystem::path(std::wstring(buffer.data(), length));
#else
const char* path = std::getenv("PATH");
return resolve_program(name, path ? path : "", std::filesystem::current_path());
#endif
}
struct Process::Impl {
#ifdef _WIN32
HANDLE process{}, thread{}, job{}, output{};
#else
pid_t pid{-1};
int output{-1};
#endif
bool running{};
std::optional<int> exit_code;
~Impl() {
try {
cancel();
} catch (...) {
}
#ifdef _WIN32
if (output)
CloseHandle(output);
if (thread)
CloseHandle(thread);
if (process)
CloseHandle(process);
if (job)
CloseHandle(job);
#else
if (output >= 0)
::close(output);
#endif
}
ProcessPoll poll() {
std::string text;
char buffer[8192];
#ifdef _WIN32
if (output) {
DWORD available{};
while (PeekNamedPipe(output, nullptr, 0, nullptr, &available, nullptr) && available) {
DWORD read{};
if (!ReadFile(output, buffer, std::min<DWORD>(available, sizeof(buffer)), &read,
nullptr) ||
!read)
break;
text.append(buffer, read);
}
}
if (running && WaitForSingleObject(process, 0) == WAIT_OBJECT_0) {
DWORD code{};
if (!GetExitCodeProcess(process, &code))
throw std::runtime_error("Cannot read process exit status");
running = false;
exit_code = static_cast<int>(code);
}
#else
if (output >= 0) {
while (true) {
auto count = ::read(output, buffer, sizeof(buffer));
if (count > 0) {
text.append(buffer, static_cast<std::size_t>(count));
continue;
}
if (count < 0 && errno == EINTR)
continue;
if (count == 0) {
::close(output);
output = -1;
} else if (errno != EAGAIN && errno != EWOULDBLOCK)
throw std::runtime_error("Cannot read child output");
break;
}
}
if (running) {
int status{};
pid_t result;
do {
result = ::waitpid(pid, &status, WNOHANG);
} while (result < 0 && errno == EINTR);
if (result == pid) {
running = false;
exit_code = WIFEXITED(status) ? WEXITSTATUS(status)
: WIFSIGNALED(status) ? 128 + WTERMSIG(status)
: 1;
} else if (result < 0)
throw std::runtime_error("Cannot collect child process");
}
#endif
if (!running) {
#ifdef _WIN32
DWORD available{};
while (output && PeekNamedPipe(output, nullptr, 0, nullptr, &available, nullptr) &&
available) {
DWORD read{};
if (!ReadFile(output, buffer, std::min<DWORD>(available, sizeof(buffer)), &read,
nullptr) ||
!read)
break;
text.append(buffer, read);
}
#else
if (output >= 0)
while (true) {
auto count = ::read(output, buffer, sizeof(buffer));
if (count > 0) {
text.append(buffer, static_cast<std::size_t>(count));
continue;
}
if (count < 0 && errno == EINTR)
continue;
if (count == 0) {
::close(output);
output = -1;
}
break;
}
#endif
}
return {running, exit_code, std::move(text)};
}
void cancel() {
if (!running)
return;
#ifdef _WIN32
if (job)
TerminateJobObject(job, 130);
TerminateProcess(process, 130);
WaitForSingleObject(process, INFINITE);
DWORD code{};
GetExitCodeProcess(process, &code);
exit_code = static_cast<int>(code);
running = false;
#else
::kill(-pid, SIGTERM);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(200);
while (std::chrono::steady_clock::now() < deadline) {
int status{};
auto result = ::waitpid(pid, &status, WNOHANG);
if (result == pid) {
running = false;
exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
::kill(-pid, SIGKILL);
return;
}
if (result < 0 && errno != EINTR) {
running = false;
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
::kill(-pid, SIGKILL);
int status{};
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {
}
running = false;
exit_code = 130;
#endif
}
};
Process::Process(const ProcessOptions& options) : impl_(std::make_unique<Impl>()) {
if (options.arguments.empty() || options.arguments[0].empty())
throw std::invalid_argument("Process requires an executable");
for (const auto& argument : options.arguments)
if (argument.find('\0') != std::string::npos)
throw std::invalid_argument("NUL in process argument");
auto cwd = options.working_directory.empty()
? std::filesystem::current_path()
: std::filesystem::absolute(options.working_directory);
if (!std::filesystem::is_directory(cwd))
throw std::runtime_error("Process working directory does not exist");
#ifdef _WIN32
auto program = std::filesystem::path(widen(options.arguments[0]));
if (program.has_parent_path() && program.is_relative())
program = cwd / program;
auto executable = program.has_parent_path() ? program : find_executable(options.arguments[0]);
std::wstring command;
for (const auto& argument : options.arguments) {
if (!command.empty())
command += L' ';
command += quote(widen(argument));
}
std::map<std::wstring, std::wstring, CaseInsensitive> environment;
auto block = GetEnvironmentStringsW();
if (!block)
throw std::runtime_error("Cannot read process environment");
for (auto entry = block; *entry; entry += wcslen(entry) + 1) {
std::wstring text(entry);
auto equals = text.find(L'=', text[0] == L'=' ? 1 : 0);
if (equals != std::wstring::npos)
environment[text.substr(0, equals)] = text.substr(equals + 1);
}
FreeEnvironmentStringsW(block);
for (auto& [key, value] : options.environment) {
if (key.empty() || key.find('=') != std::string::npos ||
key.find('\0') != std::string::npos || value.find('\0') != std::string::npos)
throw std::invalid_argument("Invalid environment entry");
environment[widen(key)] = widen(value);
}
std::vector<wchar_t> env;
for (auto& [key, value] : environment) {
auto item = key + L"=" + value;
env.insert(env.end(), item.begin(), item.end());
env.push_back(0);
}
env.push_back(0);
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE output_write{}, input{};
if (!CreatePipe(&impl_->output, &output_write, &security, 0))
throw std::runtime_error("Cannot create process pipe");
SetHandleInformation(impl_->output, HANDLE_FLAG_INHERIT, 0);
input = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &security,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
SIZE_T bytes{};
InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes);
std::vector<std::byte> storage(bytes);
auto* attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
bool initialized = InitializeProcThreadAttributeList(attributes, 1, 0, &bytes) != 0;
HANDLE handles[] = {output_write, input};
bool updated =
initialized && UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
handles, sizeof(handles), nullptr, nullptr) != 0;
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdOutput = startup.StartupInfo.hStdError = output_write;
startup.StartupInfo.hStdInput = input;
startup.lpAttributeList = attributes;
PROCESS_INFORMATION process{};
bool launched =
updated && CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE,
CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED |
CREATE_NEW_PROCESS_GROUP | EXTENDED_STARTUPINFO_PRESENT,
env.data(), cwd.c_str(), &startup.StartupInfo, &process) != 0;
if (initialized)
DeleteProcThreadAttributeList(attributes);
CloseHandle(output_write);
if (input != INVALID_HANDLE_VALUE)
CloseHandle(input);
if (!launched)
throw std::runtime_error("CreateProcess failed for " + options.arguments[0]);
impl_->process = process.hProcess;
impl_->thread = process.hThread;
impl_->running = true;
impl_->job = CreateJobObjectW(nullptr, nullptr);
if (!impl_->job)
throw std::runtime_error("Cannot create compiler job object");
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(impl_->job, JobObjectExtendedLimitInformation, &limits,
sizeof(limits)) ||
!AssignProcessToJobObject(impl_->job, impl_->process))
throw std::runtime_error("Cannot isolate compiler process group");
if (ResumeThread(impl_->thread) == DWORD(-1))
throw std::runtime_error("Cannot start compiler process");
#else
std::map<std::string, std::string> environment;
for (char** item = environ; *item; ++item) {
std::string text(*item);
auto separator = text.find('=');
if (separator != std::string::npos)
environment[text.substr(0, separator)] = text.substr(separator + 1);
}
for (const auto& [key, value] : options.environment) {
if (key.empty() || key.find('=') != std::string::npos ||
key.find('\0') != std::string::npos || value.find('\0') != std::string::npos)
throw std::invalid_argument("Invalid environment entry");
environment[key] = value;
}
auto executable = resolve_program(options.arguments[0], environment["PATH"], cwd);
std::vector<std::string> env_storage;
std::vector<char*> argv, envp;
for (const auto& argument : options.arguments)
argv.push_back(const_cast<char*>(argument.c_str()));
argv.push_back(nullptr);
for (const auto& [key, value] : environment)
env_storage.push_back(key + "=" + value);
for (auto& entry : env_storage)
envp.push_back(entry.data());
envp.push_back(nullptr);
int pipes[2];
if (::pipe2(pipes, O_CLOEXEC) < 0)
throw std::runtime_error("Cannot create process pipe");
int input = ::open("/dev/null", O_RDONLY | O_CLOEXEC);
if (input < 0) {
::close(pipes[0]);
::close(pipes[1]);
throw std::runtime_error("Cannot open process input");
}
auto pid = ::fork();
if (pid == 0) {
::setpgid(0, 0);
::close(pipes[0]);
if (::dup2(input, STDIN_FILENO) < 0 || ::dup2(pipes[1], STDOUT_FILENO) < 0 ||
::dup2(pipes[1], STDERR_FILENO) < 0 || ::chdir(cwd.c_str()) < 0)
::_exit(126);
::close(input);
::close(pipes[1]);
::execve(executable.c_str(), argv.data(), envp.data());
constexpr char message[] = "Cannot execute child process\n";
::write(STDERR_FILENO, message, sizeof(message) - 1);
::_exit(127);
}
::close(input);
::close(pipes[1]);
if (pid < 0) {
::close(pipes[0]);
throw std::runtime_error("Cannot fork process");
}
::setpgid(pid, pid);
impl_->pid = pid;
impl_->output = pipes[0];
impl_->running = true;
int flags = ::fcntl(pipes[0], F_GETFL, 0);
if (flags < 0 || ::fcntl(pipes[0], F_SETFL, flags | O_NONBLOCK) < 0)
throw std::runtime_error("Cannot configure process output pipe");
#endif
}
Process::~Process() = default;
Process::Process(Process&&) noexcept = default;
Process& Process::operator=(Process&&) noexcept = default;
ProcessPoll Process::poll() {
return impl_->poll();
}
void Process::cancel() {
impl_->cancel();
}
} // namespace faset
+747
View File
@@ -0,0 +1,747 @@
#include <atomic>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <faset/assets/asset_data.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.hpp>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <set>
#include <stdexcept>
#include <thread>
namespace faset::editor {
namespace fs = std::filesystem;
namespace {
struct Cancelled {};
constexpr std::size_t max_log_bytes = 8 * 1024 * 1024;
void validate_scene(const Json& scene) {
if (!scene.is_object() || scene.value("format", "") != "faset.scene" ||
scene.value("version", 0) != 1 || !scene.contains("entities") ||
!scene["entities"].is_array())
throw std::runtime_error("Cook requires a version 1 Faset scene");
if (!scene.value("instances", Json::array()).empty())
throw std::runtime_error("Resolve template instances before cooking");
int dimension = scene.value("dimension", 0);
if (dimension != 2 && dimension != 3)
throw std::runtime_error("Scene dimension must be 2 or 3");
}
void validate_component_types(const Json& scene, const Json& schema) {
std::map<std::string, int> versions;
for (const auto* type : {"faset.transform", "faset.sprite", "faset.mesh", "faset.camera",
"faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"})
versions[type] = 1;
for (const auto& type : schema.value("types", Json::array())) {
auto id = type.at("id").get<std::string>();
if (versions.contains(id))
throw std::runtime_error("Gameplay schema duplicates a component type: " + id);
versions[id] = type.value("version", 1);
}
for (const auto& entity : scene.at("entities"))
for (const auto& component : entity.value("components", Json::array())) {
const auto id = component.at("type").get<std::string>();
auto found = versions.find(id);
if (found == versions.end())
throw std::runtime_error("Cannot cook unresolved component type: " + id);
if (component.value("version", 1) != found->second)
throw std::runtime_error("Migrate component '" + id +
"' to the current gameplay schema before cooking");
}
}
std::set<std::string> asset_references(const Json& scene) {
std::set<std::string> result;
for (const auto& entity : scene.at("entities"))
for (const auto& component : entity.value("components", Json::array())) {
const auto type = component.value("type", "");
if (type != "faset.mesh" && type != "faset.sprite")
continue;
const auto fields = component.value("fields", Json::object());
auto asset = fields.value(type == "faset.sprite" ? "texture" : "asset", "");
if (asset.empty() || asset == "builtin:cube" || asset == "builtin:plane")
continue;
if (asset.starts_with("builtin:"))
throw std::runtime_error("Unknown builtin asset: " + asset);
result.insert(asset.substr(0, asset.find('#')));
}
return result;
}
void copy_required_file(const fs::path& source, const fs::path& target) {
if (!fs::is_regular_file(source) || fs::is_symlink(source))
throw std::runtime_error("Required package file missing or not a regular file: " +
source.string());
fs::create_directories(target.parent_path());
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
}
std::string executable_suffix() {
#ifdef _WIN32
return ".exe";
#else
return "";
#endif
}
fs::path build_executable(const fs::path& build, const std::string& configuration,
const std::string& target) {
for (const auto& root :
{build, build / configuration, build / "bin", build / "bin" / configuration}) {
auto file = root / (target + executable_suffix());
if (fs::is_regular_file(file))
return file;
}
throw std::runtime_error("Build did not produce " + target);
}
} // namespace
Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state},
{"stage", stage}, {"progress", progress}, {"log", log},
{"error", error}, {"result", result}};
}
void write_cooked_scene(const fs::path& path, const Json& scene) {
validate_scene(scene);
const auto payload = Json::to_cbor(scene);
std::string bytes = "FASETSCN";
bytes.reserve(20 + payload.size());
for (unsigned i = 0; i < 4; ++i)
bytes.push_back(static_cast<char>((std::uint32_t(1) >> (i * 8)) & 255));
for (unsigned i = 0; i < 8; ++i)
bytes.push_back(static_cast<char>((std::uint64_t(payload.size()) >> (i * 8)) & 255));
bytes.append(reinterpret_cast<const char*>(payload.data()), payload.size());
atomic_write(path, bytes);
}
struct BuildService::Impl {
struct Job {
mutable std::mutex mutex;
JobStatus status;
std::atomic<bool> cancelled{};
std::condition_variable finished;
Json scene;
Json asset_manifests = Json::object();
fs::path output;
};
BuildConfig config;
mutable std::mutex mutex;
std::condition_variable condition;
std::map<std::string, std::shared_ptr<Job>> jobs;
std::deque<std::shared_ptr<Job>> queue;
bool stopping{};
std::thread worker;
explicit Impl(BuildConfig c) : config(std::move(c)) {
if (config.project_root.empty() || config.engine_root.empty())
throw std::invalid_argument("Build service requires project and engine directories");
config.project_root = fs::absolute(config.project_root);
config.engine_root = fs::absolute(config.engine_root);
if (config.cache_root.empty())
config.cache_root = config.project_root / ".faset" / "cache";
else
config.cache_root = fs::absolute(config.cache_root);
if (config.build_directory.empty())
config.build_directory = config.project_root / ".faset" / "build";
else
config.build_directory = fs::absolute(config.build_directory);
if (config.configuration != "Debug" && config.configuration != "Release" &&
config.configuration != "RelWithDebInfo")
throw std::invalid_argument("Unsupported build configuration");
if (config.export_configuration != "Release" &&
config.export_configuration != "RelWithDebInfo")
throw std::invalid_argument("Export configuration must be Release or RelWithDebInfo");
fs::create_directories(config.project_root);
fs::create_directories(config.cache_root);
worker = std::thread([this] { work(); });
}
~Impl() {
{
std::lock_guard lock(mutex);
stopping = true;
for (auto& [_, job] : jobs)
job->cancelled = true;
}
condition.notify_all();
if (worker.joinable())
worker.join();
}
std::shared_ptr<Job> lookup(const std::string& id) const {
std::lock_guard lock(mutex);
auto it = jobs.find(id);
if (it == jobs.end())
throw std::out_of_range("Unknown build job: " + id);
return it->second;
}
std::string enqueue(std::string kind, Json scene = {}, fs::path output = {}) {
auto job = std::make_shared<Job>();
job->status.id = new_id();
job->status.kind = std::move(kind);
job->scene = std::move(scene);
job->output = std::move(output);
{
std::lock_guard lock(mutex);
if (stopping)
throw std::runtime_error("Build service is stopping");
jobs.emplace(job->status.id, job);
queue.push_back(job);
}
condition.notify_all();
return job->status.id;
}
void checkpoint(Job& job, std::string stage, double progress) {
if (job.cancelled)
throw Cancelled{};
std::lock_guard lock(job.mutex);
job.status.stage = std::move(stage);
job.status.progress = progress;
}
void log(Job& job, std::string_view text) {
std::lock_guard lock(job.mutex);
job.status.log.append(text);
if (job.status.log.size() > max_log_bytes)
job.status.log.erase(0, job.status.log.size() - max_log_bytes);
}
std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
if (job.cancelled)
throw Cancelled{};
std::string description = "$";
for (const auto& argument : arguments)
description += " " + Json(argument).dump();
description += '\n';
log(job, description);
Process process({std::move(arguments), cwd, {}});
std::string output;
while (true) {
if (job.cancelled) {
process.cancel();
auto final = process.poll();
log(job, final.output);
throw Cancelled{};
}
auto poll = process.poll();
log(job, poll.output);
output += poll.output;
if (output.size() > max_log_bytes)
output.erase(0, output.size() - max_log_bytes);
if (!poll.running) {
if (poll.exit_code.value_or(1) != 0)
throw std::runtime_error("Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log");
return output;
}
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
}
void validate_assets(const Json& scene) {
assets::AssetStore pipeline(config.cache_root);
for (const auto& id : asset_references(scene))
pipeline.load_asset(id);
}
Json build(Job& job, bool exporting = false) {
const auto& configuration = exporting ? config.export_configuration : config.configuration;
const auto native_directory = config.build_directory / configuration;
checkpoint(job, "Configuring C++ gameplay", .05);
if (!fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.cpp") ||
!fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.hpp"))
throw std::runtime_error("Project Scripts/Gameplay.cpp and Gameplay.hpp are required; "
"create a project scaffold first");
fs::create_directories(native_directory);
std::vector<std::string> arguments = {config.cmake,
"-S",
config.engine_root.string(),
"-B",
native_directory.string(),
"-G",
config.generator,
"-DCMAKE_BUILD_TYPE=" + configuration,
"-DBUILD_TESTING=OFF",
"-DFASET_BUILD_EDITOR=OFF",
"-DFASET_BUILD_RENDERER=ON",
"-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
(config.project_root / "Scripts").string()};
bool compiler_overridden = false;
for (const auto& arg : config.configure_arguments)
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
compiler_overridden = true;
if (!compiler_overridden) {
#ifdef _WIN32
auto compiler = find_executable("clang-cl");
arguments.push_back("-DCMAKE_C_COMPILER=" + compiler.string());
arguments.push_back("-DCMAKE_CXX_COMPILER=" + compiler.string());
#else
arguments.push_back("-DCMAKE_C_COMPILER=" + find_executable("clang").string());
arguments.push_back("-DCMAKE_CXX_COMPILER=" + find_executable("clang++").string());
#endif
}
arguments.insert(arguments.end(), config.configure_arguments.begin(),
config.configure_arguments.end());
arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
run(job, std::move(arguments), config.project_root);
checkpoint(job, "Compiling and linking Player", .25);
run(job,
{config.cmake, "--build", native_directory.string(), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root);
checkpoint(job, "Exporting gameplay schema", .58);
auto player = build_executable(native_directory, configuration, "faset_player");
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter");
const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id);
const auto generation = config.cache_root / "builds" / job.status.id;
fs::create_directories(staging);
try {
const auto schema_file = staging / "schema.json";
run(job, {exporter.string(), "--output", schema_file.string()}, config.project_root);
auto schema = read_json(schema_file);
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
!schema.contains("types") || !schema.at("types").is_array())
throw std::runtime_error("SchemaExporter returned an invalid manifest");
std::string fingerprint = sha256_file(player) + sha256_file(exporter) +
read_text(native_directory / "CMakeCache.txt");
for (const auto& file : {"Gameplay.cpp", "Gameplay.hpp"})
fingerprint += read_text(config.project_root / "Scripts" / file);
fingerprint = sha256(fingerprint);
schema["build_fingerprint"] = fingerprint;
atomic_write_json(schema_file, schema);
copy_required_file(player, staging / ("faset_player" + executable_suffix()));
copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix()));
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json manifest{{"format", "faset.build"},
{"version", 1},
{"id", job.status.id},
{"fingerprint", fingerprint},
{"configuration", configuration},
{"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68);
fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
return {{"generation", job.status.id},
{"directory", generation.string()},
{"build_directory", native_directory.string()},
{"configuration", configuration},
{"player", (generation / ("faset_player" + executable_suffix())).string()},
{"schema", (generation / "schema.json").string()},
{"fingerprint", fingerprint}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
throw;
}
}
void copy_runtime_libraries(Job& job, const fs::path& executable, const fs::path& destination,
const fs::path& native_directory,
const std::string& configuration) {
#ifdef _WIN32
// Libraries produced by the selected toolchain are copied beside the executable.
// System DLLs (including Vulkan) remain platform prerequisites.
for (const auto& root :
{executable.parent_path(), native_directory, native_directory / configuration})
if (fs::is_directory(root))
for (const auto& entry : fs::directory_iterator(root)) {
auto extension = entry.path().extension().string();
for (auto& c : extension)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (entry.is_regular_file() && extension == ".dll")
copy_required_file(entry.path(), destination / entry.path().filename());
}
// The actual packaged executable is launched before publication to reject missing imports.
(void)job;
#else
const auto output =
run(job, {find_executable("ldd").string(), executable.string()}, config.project_root);
if (output.find("not found") != std::string::npos)
throw std::runtime_error("Player has unresolved shared library dependencies");
// SDL/physics/gameplay are linked statically. glibc/libstdc++/Vulkan are the host baseline.
// Refuse an unexpected private DSO rather than silently publish a non-portable package.
std::size_t begin{};
while (begin < output.size()) {
auto end = output.find('\n', begin);
auto line = output.substr(begin, end == std::string::npos ? end : end - begin);
auto arrow = line.find("=> ");
if (arrow != std::string::npos) {
auto name = line.substr(0, arrow);
name.erase(0, name.find_first_not_of(" \t"));
auto path_start = arrow + 3;
auto path_end = line.find(" (", path_start);
auto path = line.substr(path_start, path_end - path_start);
if (!path.empty() && path[0] == '/' && !path.starts_with("/lib/") &&
!path.starts_with("/lib64/") && !path.starts_with("/usr/lib/") &&
!path.starts_with("/usr/lib64/"))
throw std::runtime_error(
"Private shared library needs an explicit package rule: " + path);
}
if (end == std::string::npos)
break;
begin = end + 1;
}
(void)destination;
(void)native_directory;
(void)configuration;
#endif
}
Json cook(Job& job) {
checkpoint(job, "Validating scene and assets", .1);
validate_scene(job.scene);
validate_assets(job.scene);
Json schema = Json::object();
if (fs::exists(config.cache_root / "last_build.json")) {
auto pointer = read_json(config.cache_root / "last_build.json");
auto schema_path = project_path(
config.cache_root,
fs::path("builds") / pointer.at("generation").get<std::string>() / "schema.json");
schema = read_json(schema_path);
}
validate_component_types(job.scene, schema);
auto source_hash = sha256(job.scene.dump());
auto schema_fingerprint = schema.value("build_fingerprint", std::string("builtin-v1"));
auto digest = sha256(source_hash + schema_fingerprint);
auto directory = config.cache_root / "cooked" / digest;
auto staging = config.cache_root / "cooked" / (".staging-" + job.status.id);
fs::create_directories(staging);
try {
checkpoint(job, "Writing cooked scene", .6);
write_cooked_scene(staging / "scene.fscene", job.scene);
Json manifest{{"format", "faset.cooked-scene"},
{"version", 1},
{"scene", job.scene.value("id", "")},
{"source_hash", source_hash},
{"schema_fingerprint", schema_fingerprint},
{"sha256", sha256_file(staging / "scene.fscene")}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing cooked scene", .9);
if (fs::exists(directory)) {
if (read_json(directory / "manifest.json") != manifest ||
sha256_file(directory / "scene.fscene") !=
manifest.at("sha256").get<std::string>())
throw std::runtime_error("Existing cooked generation is corrupt");
fs::remove_all(staging);
} else
fs::rename(staging, directory);
atomic_write_json(config.cache_root / "last_cook.json", {{"generation", digest}});
return {{"generation", digest},
{"scene", (directory / "scene.fscene").string()},
{"directory", directory.string()}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
throw;
}
}
void package_notices(const fs::path& destination, const fs::path& native_directory) {
fs::create_directories(destination);
auto lock = read_json(config.engine_root / "dependencies.lock.json");
const std::vector<std::string> runtime_dependencies = {"sdl3", "entt", "box2d",
"box3d", "json", "stb"};
Json used = Json::object();
for (const auto& name : runtime_dependencies) {
std::vector<fs::path> roots = {native_directory / "_deps" / (name + "-src"),
config.engine_root / ".cache" / "deps-src" / name};
bool copied{};
for (const auto& source : roots) {
if (!fs::is_directory(source))
continue;
for (const auto& entry : fs::directory_iterator(source)) {
if (!entry.is_regular_file())
continue;
auto filename = entry.path().filename().string();
std::string upper = filename;
for (auto& c : upper)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") ||
upper.starts_with("NOTICE")) {
copy_required_file(entry.path(), destination / name / filename);
copied = true;
}
}
if (copied)
break;
}
if (!copied)
throw std::runtime_error("Cannot package required license notices for " + name);
used[name] = lock.at("dependencies").at(name);
}
atomic_write_json(destination / "dependencies.json", used);
if (fs::is_regular_file(config.engine_root / "LICENSE"))
copy_required_file(config.engine_root / "LICENSE", destination / "Faset-LICENSE");
atomic_write(destination / "Faset-NOTICE.txt",
"Faset Engine\nhttps://github.com/emil28092005/Faset_Engine\nSee the source "
"repository for the current license status of Faset's own code.\nThird-party "
"license texts are included in the adjacent directories.\n");
}
void package_assets(Job& job, const fs::path& destination) {
assets::AssetStore packaged(destination);
for (const auto& id : asset_references(job.scene)) {
if (job.cancelled)
throw Cancelled{};
auto manifest = job.asset_manifests.at(id);
auto generation = manifest.at("generation").get<std::string>();
auto source_directory = project_path(config.cache_root, fs::path("assets") / id /
"generations" / generation);
auto target = destination / "assets" / id / "generations" / generation;
for (const auto& file : manifest.at("files")) {
auto relative = fs::path(file.at("path").get<std::string>());
copy_required_file(project_path(source_directory, relative),
project_path(target, relative));
}
manifest["source"] = "<cooked>";
if (manifest.contains("payload_source"))
manifest["payload_source"] = "<cooked>";
atomic_write_json(target / "manifest.json", manifest);
atomic_write_json(
destination / "assets" / id / "current.json",
{{"schema_version", 1}, {"generation", generation}, {"source", "<cooked>"}});
packaged.load_asset(id);
}
}
Json export_game(Job& job) {
validate_scene(job.scene);
validate_assets(job.scene);
assets::AssetStore source(config.cache_root);
for (const auto& id : asset_references(job.scene))
job.asset_manifests[id] = source.current_manifest(id);
const auto built = build(job, true);
validate_component_types(job.scene, read_json(built.at("schema").get<std::string>()));
auto output = fs::absolute(job.output);
if (output.empty())
throw std::runtime_error("An export destination is required");
fs::create_directories(output / "generations");
auto staging = output / (".staging-" + job.status.id);
auto generation = output / "generations" / job.status.id;
fs::create_directories(staging);
try {
checkpoint(job, "Cooking export snapshot", .72);
write_cooked_scene(staging / "scene.fscene", job.scene);
auto build_directory = fs::path(built.at("directory").get<std::string>());
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"})
copy_required_file(build_directory / "shaders" / shader,
staging / "shaders" / shader);
for (const auto& entry : fs::directory_iterator(build_directory)) {
auto extension = entry.path().extension().string();
for (auto& c : extension)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (entry.is_regular_file() && extension == ".dll")
copy_required_file(entry.path(), staging / entry.path().filename());
}
checkpoint(job, "Packaging assets and notices", .80);
package_assets(job, staging);
package_notices(staging / "Notices", built.at("build_directory").get<std::string>());
atomic_write(
staging / "README.txt",
"Run faset_player" + executable_suffix() +
" to start this game.\nThe executable loads scene.fscene and assets beside "
"it.\nKeep shaders/, assets/, and Notices/ with the executable.\nA compatible "
"Vulkan 1.3 driver and the supported OS runtime are required.\n");
#ifdef _WIN32
atomic_write(staging / "Windows-Runtime.txt",
"Install the Microsoft Visual C++ x64 Redistributable if Windows reports "
"a missing MSVCP140 or VCRUNTIME140 DLL.\nOfficial installer: "
"https://aka.ms/vc14/vc_redist.x64.exe\nThe Vulkan loader and GPU driver "
"must also be installed.\n");
#endif
checkpoint(job, "Validating packaged Player", .90);
auto validation_output =
run(job,
{(staging / ("faset_player" + executable_suffix())).string(), "--validate",
"--scene", (staging / "scene.fscene").string(), "--assets", staging.string()},
staging);
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
if (entry.is_symlink())
throw std::runtime_error("Export contains a symlink");
if (entry.is_regular_file())
files.push_back(
{{"path", entry.path().lexically_relative(staging).generic_string()},
{"sha256", sha256_file(entry.path())},
{"size", entry.file_size()}});
}
Json manifest{{"format", "faset.export"},
{"version", 1},
{"generation", job.status.id},
{"build_fingerprint", built.at("fingerprint")},
{"scene_hash", sha256(job.scene.dump())},
{"asset_generations", Json::object()},
{"configuration", built.at("configuration")},
{"executable", "faset_player" + executable_suffix()},
{"files", files}};
manifest["units"] = {
{"distance", "metre"}, {"angle", "radian"}, {"coordinates", "right-handed Y-up"}};
manifest["simulation"] = {{"fixed_delta", 1.0 / 60.0},
{"max_catch_up_ticks", 4},
{"physics_substeps", 4},
{"gravity", {0, -9.81, 0}}};
if (job.scene.contains("simulation"))
manifest["simulation"].update(job.scene.at("simulation"));
manifest["renderer_profile"] = {
{"api", "Vulkan 1.3"},
{"required_features", {"dynamicRendering", "synchronization2"}},
{"materials", {"base-color factor and texture", "metallic and roughness factors"}},
{"texture_sampling", "linear clamp, one mip level"},
{"shadow_map", {{"resolution", 1024}, {"world_extent", 40}}},
{"unsupported_material_features",
{"normal maps", "metallic-roughness maps", "emissive and occlusion maps",
"alpha mode selection", "unlit mode", "per-material face culling"}}};
manifest["validation_log"] = validation_output;
for (const auto& [id, asset] : job.asset_manifests.items())
manifest["asset_generations"][id] = asset.at("generation");
#ifdef _WIN32
manifest["platform"] = "windows";
manifest["prerequisites"] = {
"Windows x64", "Vulkan 1.3 driver",
"Microsoft Visual C++ x64 Redistributable (Visual Studio 2022 or newer)"};
#else
manifest["platform"] = "linux";
manifest["prerequisites"] = {"Linux x86_64", "Vulkan 1.3 driver",
"Compatible glibc and libstdc++ runtime"};
#endif
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing export generation", .98);
fs::rename(staging, generation);
atomic_write_json(output / "current.json",
{{"format", "faset.export-pointer"},
{"version", 1},
{"generation", job.status.id},
{"directory", "generations/" + job.status.id}});
return {{"directory", generation.string()},
{"executable", (generation / ("faset_player" + executable_suffix())).string()},
{"manifest", (generation / "manifest.json").string()},
{"generation", job.status.id},
{"build", built},
{"schema", built.at("schema")},
{"player", built.at("player")},
{"build_directory", built.at("build_directory")},
{"configuration", built.at("configuration")}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
throw;
}
}
void work() {
while (true) {
std::shared_ptr<Job> job;
{
std::unique_lock lock(mutex);
condition.wait(lock, [&] { return stopping || !queue.empty(); });
if (queue.empty()) {
if (stopping)
return;
continue;
}
job = queue.front();
queue.pop_front();
}
{
std::lock_guard lock(job->mutex);
job->status.state = "running";
}
try {
if (job->cancelled)
throw Cancelled{};
Json result;
if (job->status.kind == "build")
result = build(*job);
else if (job->status.kind == "cook")
result = cook(*job);
else
result = export_game(*job);
std::lock_guard lock(job->mutex);
job->status.state = "succeeded";
job->status.stage = "Complete";
job->status.progress = 1;
job->status.result = std::move(result);
} catch (const Cancelled&) {
std::lock_guard lock(job->mutex);
job->status.state = "cancelled";
job->status.stage = "Cancelled";
job->status.error =
"Job cancelled; previous published generations remain available";
} catch (const std::exception& error) {
std::lock_guard lock(job->mutex);
job->status.state = "failed";
job->status.stage = "Failed";
job->status.error = error.what();
}
job->finished.notify_all();
condition.notify_all();
}
}
};
BuildService::BuildService(BuildConfig config) : impl_(std::make_unique<Impl>(std::move(config))) {}
BuildService::~BuildService() = default;
const BuildConfig& BuildService::config() const {
return impl_->config;
}
void BuildService::scaffold(const std::string& name, int dimension) {
if (name.empty() || (dimension != 2 && dimension != 3))
throw std::invalid_argument("Project name and dimension 2 or 3 required");
const auto& c = impl_->config;
fs::create_directories(c.project_root / "Scripts");
fs::create_directories(c.project_root / "Scenes");
fs::create_directories(c.project_root / "Assets");
for (const auto* file : {"Gameplay.cpp", "Gameplay.hpp"}) {
auto target = c.project_root / "Scripts" / file;
if (!fs::exists(target)) {
auto source = c.engine_root / "tools" / "project_templates" / file;
copy_required_file(source, target);
}
}
auto project = c.project_root / "project.faset.json";
if (!fs::exists(project))
atomic_write_json(project, {{"format", "faset.project"},
{"version", 1},
{"id", new_id()},
{"name", name},
{"dimension", dimension},
{"start_scene", "Scenes/main.scene.json"}});
if (!fs::exists(c.project_root / ".gitignore"))
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n");
}
std::string BuildService::start_build() {
return impl_->enqueue("build");
}
std::string BuildService::start_cook(Json scene) {
return impl_->enqueue("cook", std::move(scene));
}
std::string BuildService::start_export(Json scene, const fs::path& output) {
if (output.empty())
throw std::invalid_argument("Export output directory is required");
return impl_->enqueue("export", std::move(scene), output);
}
JobStatus BuildService::job(const std::string& id) const {
auto value = impl_->lookup(id);
std::lock_guard lock(value->mutex);
return value->status;
}
std::vector<JobStatus> BuildService::jobs() const {
std::vector<std::shared_ptr<Impl::Job>> values;
{
std::lock_guard lock(impl_->mutex);
for (auto& [_, value] : impl_->jobs)
values.push_back(value);
}
std::vector<JobStatus> result;
for (auto& value : values) {
std::lock_guard lock(value->mutex);
result.push_back(value->status);
}
return result;
}
void BuildService::cancel(const std::string& id) {
impl_->lookup(id)->cancelled = true;
impl_->condition.notify_all();
}
JobStatus BuildService::wait(const std::string& id) {
auto value = impl_->lookup(id);
std::unique_lock lock(value->mutex);
value->finished.wait(lock, [&] { return value->status.finished(); });
return value->status;
}
} // namespace faset::editor
+157
View File
@@ -0,0 +1,157 @@
#include <algorithm>
#include <faset/authoring/templates.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/commands.hpp>
#include <set>
namespace faset::editor {
Json Commands::object_schema(Json properties, Json required) {
return {{"type", "object"},
{"properties", std::move(properties)},
{"required", std::move(required)},
{"additionalProperties", false}};
}
void Commands::add(std::string name, std::string description, Json schema, Handler handler,
bool read_only) {
require(!commands_.contains(name), "command.duplicate", "Command already registered: " + name);
Json descriptor = {{"name", name},
{"description", std::move(description)},
{"inputSchema", std::move(schema)},
{"annotations", {{"readOnlyHint", read_only}, {"openWorldHint", false}}}};
commands_.emplace(std::move(name), Command{std::move(descriptor), std::move(handler)});
}
void Commands::remove(const std::string& name) {
commands_.erase(name);
}
Json Commands::list() const {
Json result = Json::array();
for (const auto& [name, command] : commands_)
result.push_back(command.descriptor);
return result;
}
Json Commands::call(const std::string& name, const Json& arguments) {
const auto found = commands_.find(name);
require(found != commands_.end(), "command.unknown", "Unknown editor command: " + name);
require(arguments.is_object(), "arguments.object", "Tool arguments must be an object");
const auto& schema = found->second.descriptor["inputSchema"];
for (const auto& key : schema.value("required", Json::array()))
require(arguments.contains(key.get<std::string>()), "arguments.required",
"Missing argument: " + key.get<std::string>());
for (const auto& [key, value] : arguments.items()) {
require(schema["properties"].contains(key), "arguments.unknown",
"Unknown argument: " + key);
const auto& property = schema["properties"][key];
const auto type = property.value("type", std::string());
const bool valid =
type.empty() || (type == "string" && value.is_string()) ||
(type == "boolean" && value.is_boolean()) || (type == "number" && value.is_number()) ||
(type == "integer" && value.is_number_integer()) ||
(type == "object" && value.is_object()) || (type == "array" && value.is_array());
require(valid, "arguments.type", "Invalid type for argument: " + key);
if (property.contains("enum"))
require(std::find(property["enum"].begin(), property["enum"].end(), value) !=
property["enum"].end(),
"arguments.enum", "Unsupported value for argument: " + key);
if (property.contains("maximum") && value.is_number())
require(value.get<double>() <= property["maximum"].get<double>(), "arguments.maximum",
"Argument exceeds its maximum: " + key);
if (property.contains("minimum") && value.is_number())
require(value.get<double>() >= property["minimum"].get<double>(), "arguments.minimum",
"Argument is below its minimum: " + key);
}
try {
return found->second.handler(arguments);
} catch (const Json::exception& error) {
throw Error("arguments.invalid", "Invalid command data", {{"reason", error.what()}});
}
}
Json Commands::resolved_scene(const std::string& id) const {
const auto result = authoring::resolve_templates(
authoring_.query(id).at("scene"), authoring_.schemas(),
[&](const std::string& path) { return read_json(project_path(authoring_.root(), path)); });
return {{"scene", result.scene}, {"conflicts", result.conflicts}};
}
Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authoring) {
const Json text = {{"type", "string"}}, integer = {{"type", "integer"}, {"minimum", 0}},
boolean = {{"type", "boolean"}};
add(
"faset_documents",
"List open authoring documents and their revisions. This does not inspect a running game.",
object_schema(Json::object()),
[&](const Json&) { return Json{{"documents", authoring_.documents()}}; }, true);
add("faset_document_create",
"Create an unsaved 2D or 3D scene. Returns its persistent document ID and revision.",
object_schema({{"name", text}, {"dimension", integer}}, {"name", "dimension"}),
[&](const Json& args) { return authoring_.create(args.at("name"), args.at("dimension")); });
add("faset_document_open",
"Open a scene relative to the project. Set recover=true to load its saved recovery "
"journal.",
object_schema({{"path", text}, {"recover", boolean}}, {"path"}), [&](const Json& args) {
return authoring_.open(args.at("path").get<std::string>(),
args.value("recover", false));
});
add(
"faset_document_query",
"Read an authoring scene, persistent IDs, dirty state and revision. No Player or runtime "
"state is exposed.",
object_schema({{"document", text}}, {"document"}),
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
add("faset_document_save",
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
return authoring_.save(args.at("document"), args.value("path", std::string()));
});
add(
"faset_schema",
"Inspect registered component TypeIds, stable FieldIds, defaults and constraints.",
object_schema(Json::object()), [&](const Json&) { return authoring_.schemas().manifest(); },
true);
add("faset_scene_edit",
"Apply one atomic authoring batch with optimistic revision checking and one Undo step. "
"Operations: entity.create/rename/delete/duplicate/reparent; component.add/remove/set; "
"scene.rename; template.instance/override/revert/suppress/add/reparent. Use persistent IDs "
"from document_query and schema. An idempotency_key retries the same payload in this "
"session.",
object_schema({{"document", text},
{"revision", integer},
{"operations", {{"type", "array"}, {"items", {{"type", "object"}}}}},
{"idempotency_key", text}},
{"document", "revision", "operations"}),
[&](const Json& args) {
return authoring_.transact(args.at("document"), args.at("revision"),
args.at("operations"),
args.value("idempotency_key", std::string()));
});
add("faset_undo", "Undo one authoring transaction. Requires the current document revision.",
object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}),
[&](const Json& args) {
return authoring_.undo(args.at("document"), args.at("revision"));
});
add("faset_redo", "Redo one authoring transaction. Requires the current document revision.",
object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}),
[&](const Json& args) {
return authoring_.redo(args.at("document"), args.at("revision"));
});
add(
"faset_template_preview",
"Resolve authoring templates and report conflicts without changing source documents. This "
"is not a live game query.",
object_schema({{"document", text}}, {"document"}),
[&](const Json& args) { return resolved_scene(args.at("document")); }, true);
add("faset_recovery_restore",
"Restore a recovery journal, including an unsaved new scene. If the document is already "
"open, pass its current revision. External file changes are never overwritten.",
object_schema({{"document", text}, {"revision", integer}}, {"document"}),
[&](const Json& args) {
return authoring_.recover(
args.at("document"),
args.contains("revision")
? std::optional<std::uint64_t>(args.at("revision").get<std::uint64_t>())
: std::nullopt);
});
add(
"faset_recovery_list", "List document recovery records in this project.",
object_schema(Json::object()),
[&](const Json&) { return Json{{"recovery", authoring_.recovery_documents()}}; }, true);
}
} // namespace faset::editor
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
#include <faset/editor/mcp.hpp>
#include <iostream>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <cerrno>
#include <poll.h>
#include <unistd.h>
#endif
namespace faset::editor {
namespace {
Json rpc_error(Json id, int code, std::string message, Json data = Json::object()) {
return {
{"jsonrpc", "2.0"},
{"id", id},
{"error", {{"code", code}, {"message", std::move(message)}, {"data", std::move(data)}}}};
}
} // namespace
Json McpServer::parse_error() const {
return rpc_error(nullptr, -32700, "Parse error");
}
std::optional<Json> McpServer::handle(const Json& message) {
if (!message.is_object() || !message.contains("jsonrpc") || message["jsonrpc"] != "2.0" ||
!message.contains("method") || !message["method"].is_string())
return rpc_error(nullptr, -32600, "Invalid Request");
const auto method = message.at("method").get<std::string>();
if (!message.contains("id")) {
if (method == "notifications/initialized" && initialized_)
ready_ = true;
return std::nullopt;
}
const Json id = message.at("id");
if (!(id.is_string() || id.is_number_integer()))
return rpc_error(nullptr, -32600, "Request ID must be a string or integer");
const auto params = message.value("params", Json::object());
if (!params.is_object())
return rpc_error(id, -32602, "Invalid params");
auto result = [&](Json value) {
return Json{{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(value)}};
};
if (method == "ping")
return result(Json::object());
if (method == "initialize") {
if (initialized_)
return rpc_error(id, -32600, "Session already initialized");
if (!params.contains("protocolVersion") || !params["protocolVersion"].is_string())
return rpc_error(id, -32602, "protocolVersion is required");
initialized_ = true;
return result(
{{"protocolVersion", "2025-06-18"},
{"serverInfo", {{"name", "faset-editor"}, {"version", FASET_VERSION}}},
{"capabilities", {{"tools", Json::object()}, {"resources", Json::object()}}},
{"instructions",
"Faset tools edit project documents and manage editor jobs. Query revisions before "
"writes. Player/runtime world access is intentionally unavailable."}});
}
if (!ready_)
return rpc_error(id, -32002, "Initialize the session before using editor tools");
if (method == "tools/list")
return result({{"tools", commands_.list()}});
if (method == "tools/call") {
if (!params.contains("name") || !params["name"].is_string())
return rpc_error(id, -32602, "Tool name is required");
try {
auto value =
commands_.call(params.at("name"), params.value("arguments", Json::object()));
Json content = Json::array();
if (value.is_object() && value.contains("image_base64")) {
content.push_back(
{{"type", "image"},
{"data", value.at("image_base64")},
{"mimeType", value.value("mimeType", std::string("image/png"))}});
value.erase("image_base64");
}
content.push_back({{"type", "text"}, {"text", value.dump()}});
return result({{"content", content}, {"structuredContent", value}, {"isError", false}});
} catch (const Error& error) {
const auto value = Json{{"error", error.json()}};
return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})},
{"structuredContent", value},
{"isError", true}});
} catch (const std::exception& error) {
const auto value =
Json{{"error", {{"code", "editor.failure"}, {"message", error.what()}}}};
return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})},
{"structuredContent", value},
{"isError", true}});
}
}
if (method == "resources/list")
return result({{"resources", Json::array({{{"uri", "faset://schema"},
{"name", "Component schema"},
{"mimeType", "application/json"}},
{{"uri", "faset://documents"},
{"name", "Open authoring documents"},
{"mimeType", "application/json"}}})}});
if (method == "resources/read") {
if (!params.contains("uri") || !params["uri"].is_string())
return rpc_error(id, -32602, "Resource URI must be a string");
const auto uri = params.at("uri").get<std::string>();
Json value;
if (uri == "faset://schema")
value = commands_.authoring().schemas().manifest();
else if (uri == "faset://documents")
value = commands_.authoring().documents();
else
return rpc_error(id, -32602, "Unknown resource URI");
return result({{"contents", Json::array({{{"uri", uri},
{"mimeType", "application/json"},
{"text", value.dump()}}})}});
}
return rpc_error(id, -32601, "Method not found");
}
std::vector<std::string> StdioTransport::poll() {
std::vector<std::string> lines;
if (closed_)
return lines;
char bytes[65536];
std::size_t count = 0;
#ifdef _WIN32
const auto input = GetStdHandle(STD_INPUT_HANDLE);
const auto type = GetFileType(input);
DWORD available = 0;
if (type == FILE_TYPE_PIPE) {
if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) {
closed_ = true;
return lines;
}
} else if (type == FILE_TYPE_DISK)
available = sizeof(bytes);
else
return lines;
if (available) {
DWORD read = 0;
if (!ReadFile(input, bytes, std::min<DWORD>(available, sizeof(bytes)), &read, nullptr) ||
read == 0)
closed_ = true;
count = read;
}
#else
pollfd input{STDIN_FILENO, POLLIN, 0};
if (::poll(&input, 1, 0) > 0 && (input.revents & (POLLIN | POLLHUP))) {
const auto read = ::read(STDIN_FILENO, bytes, sizeof(bytes));
if (read > 0)
count = static_cast<std::size_t>(read);
else if (read == 0)
closed_ = true;
else if (errno != EINTR && errno != EAGAIN)
closed_ = true;
}
#endif
buffer_.append(bytes, count);
require(buffer_.size() <= 8 * 1024 * 1024, "mcp.message_size", "MCP input exceeds 8 MiB");
std::size_t newline = 0;
while ((newline = buffer_.find('\n')) != std::string::npos) {
auto line = buffer_.substr(0, newline);
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!line.empty())
lines.push_back(std::move(line));
buffer_.erase(0, newline + 1);
}
if (closed_ && !buffer_.empty()) {
lines.push_back(std::move(buffer_));
buffer_.clear();
}
return lines;
}
void StdioTransport::send(const Json& value) {
std::cout << value.dump() << '\n';
std::cout.flush();
}
} // namespace faset::editor
+358
View File
@@ -0,0 +1,358 @@
#include <algorithm>
#include <cctype>
#include <faset/core/io.hpp>
#include <faset/editor/plugin_api.h>
#include <faset/editor/plugins.hpp>
#include <set>
#include <thread>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <dlfcn.h>
#endif
namespace faset::editor {
namespace {
void append(void* context, const char* bytes, std::uint64_t size) {
auto& output = *static_cast<std::string*>(context);
require(size <= 8 * 1024 * 1024 && output.size() + size <= 8 * 1024 * 1024,
"plugin.output_limit", "Plugin response exceeds 8 MiB");
output.append(bytes, static_cast<std::size_t>(size));
}
Json response(const std::string& bytes) {
auto value = Json::parse(bytes);
require(value.is_object(), "plugin.response", "Plugin response must be a JSON object");
return value;
}
bool identifier(const std::string& value) {
return !value.empty() && value.size() <= 128 &&
std::all_of(value.begin(), value.end(), [](unsigned char c) {
return c < 128 && (std::isalnum(c) || c == '.' || c == '_' || c == '-');
});
}
std::string prefix(std::string id) {
for (auto& c : id)
if (c == '.' || c == '-')
c = '_';
return "plugin_" + id + "_";
}
} // namespace
struct PluginManager::Impl {
struct Registration {
Json descriptor;
FasetCommand callback = nullptr;
void* user = nullptr;
};
struct Module {
Impl* owner = nullptr;
Json manifest;
std::filesystem::path directory;
std::vector<Registration> commands;
Json panels = Json::array();
std::vector<std::string> installed;
FasetEditorHost host{};
FasetEditorPlugin plugin{};
void* library = nullptr;
bool ready = false;
std::string failure;
~Module() {
ready = false;
for (const auto& name : installed)
owner->commands.remove(name);
if (plugin.shutdown)
try {
plugin.shutdown(plugin.user);
} catch (...) { /* Plugin contract forbids exceptions. */
}
#ifdef _WIN32
if (library)
FreeLibrary(static_cast<HMODULE>(library));
#else
if (library)
dlclose(library);
#endif
}
std::string id() const {
return manifest.at("id");
}
void check_thread() const {
require(std::this_thread::get_id() == owner->thread, "plugin.thread",
"Editor SDK calls must run on the Editor thread");
}
};
Commands& commands;
Logger logger;
std::thread::id thread = std::this_thread::get_id();
std::vector<std::unique_ptr<Module>> modules;
Json records = Json::array();
bool attempted = false;
unsigned call_depth = 0;
Impl(Commands& value, Logger output) : commands(value), logger(std::move(output)) {}
~Impl() {
while (!modules.empty())
modules.pop_back();
}
static int command(void* context, const char* descriptor, FasetCommand callback, void* user) {
auto& module = *static_cast<Module*>(context);
try {
module.check_thread();
require(!module.ready, "plugin.registration_closed", "Registrations are startup-only");
require(callback, "plugin.callback", "Command callback is missing");
const auto value = Json::parse(descriptor);
const auto name = value.at("name").get<std::string>();
require(name.starts_with(prefix(module.id())) && identifier(name),
"plugin.command_owner", "Plugin command must use its module prefix");
require(value.at("description").is_string() &&
value.at("inputSchema").at("type") == "object" &&
value.at("inputSchema").at("properties").is_object(),
"plugin.command_schema", "Command requires an object input schema");
for (const auto& prior : module.commands)
require(prior.descriptor.at("name") != name, "plugin.command_duplicate",
"Duplicate plugin command");
module.commands.push_back({value, callback, user});
return 0;
} catch (const std::exception& error) {
module.failure = error.what();
return 1;
}
}
static int panel(void* context, const char* descriptor) {
auto& module = *static_cast<Module*>(context);
try {
module.check_thread();
require(!module.ready, "plugin.registration_closed", "Registrations are startup-only");
auto value = Json::parse(descriptor);
require(value.at("id").get<std::string>().starts_with(module.id() + "."),
"plugin.panel_owner", "Panel ID must belong to its module");
require(value.at("title").is_string() && value.at("command").is_string(),
"plugin.panel", "Panel needs a title and command");
if (!value.contains("arguments"))
value["arguments"] = Json::object();
require(value["arguments"].is_object(), "plugin.panel",
"Panel arguments must be an object");
for (const auto& previous : module.panels)
require(previous["id"] != value["id"], "plugin.panel_duplicate",
"Duplicate panel ID");
value["owner"] = module.id();
module.panels.push_back(std::move(value));
return 0;
} catch (const std::exception& error) {
module.failure = error.what();
return 1;
}
}
static int invoke(void* context, const char* name, const char* arguments, FasetWrite write,
void* receiver) {
auto& module = *static_cast<Module*>(context);
bool entered = false;
try {
module.check_thread();
require(module.ready, "plugin.not_ready",
"Editor commands become available after plugin startup");
require(module.owner->call_depth < 32, "plugin.recursion",
"Plugin command recursion limit exceeded");
++module.owner->call_depth;
entered = true;
const auto output = module.owner->commands.call(name, Json::parse(arguments)).dump();
--module.owner->call_depth;
entered = false;
write(receiver, output.data(), output.size());
return 0;
} catch (const std::exception& error) {
if (entered)
--module.owner->call_depth;
const auto* known = dynamic_cast<const Error*>(&error);
const auto output =
(known ? known->json()
: Json{{"code", "plugin.command"}, {"message", error.what()}})
.dump();
if (write)
try {
write(receiver, output.data(), output.size());
} catch (...) {
}
return 1;
}
}
static void log(void* context, const char* text) {
auto& module = *static_cast<Module*>(context);
try {
module.check_thread();
module.owner->logger(module.id() + ": " + text);
} catch (...) {
}
}
void activate(const Json& manifest, const std::filesystem::path& directory) {
auto module = std::make_unique<Module>();
module->owner = this;
module->manifest = manifest;
module->directory = directory;
const auto path = project_path(directory, manifest.at("library").get<std::string>());
require(std::filesystem::is_regular_file(path), "plugin.library",
"Plugin library is missing");
#ifdef _WIN32
module->library =
LoadLibraryExW(path.c_str(), nullptr,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
auto entry = module->library
? reinterpret_cast<FasetPluginEntry>(GetProcAddress(
static_cast<HMODULE>(module->library), "faset_editor_plugin"))
: nullptr;
#else
module->library = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
const auto error = module->library ? nullptr : dlerror();
require(module->library, "plugin.load", error ? error : "Cannot load plugin library");
auto entry =
reinterpret_cast<FasetPluginEntry>(dlsym(module->library, "faset_editor_plugin"));
#endif
require(module->library && entry, "plugin.entry",
"Library does not export faset_editor_plugin");
module->host = {FASET_EDITOR_API_VERSION,
sizeof(FasetEditorHost),
FASET_EDITOR_SDK_FINGERPRINT,
module.get(),
command,
panel,
invoke,
log};
require(entry(&module->host, &module->plugin) == 0, "plugin.startup",
"Plugin startup failed");
require(module->failure.empty(), "plugin.registration", module->failure);
require(module->plugin.api_version == FASET_EDITOR_API_VERSION &&
module->plugin.struct_size == sizeof(FasetEditorPlugin),
"plugin.api", "Plugin returned an incompatible API");
require(module->plugin.build_fingerprint &&
std::string(module->plugin.build_fingerprint) == FASET_EDITOR_SDK_FINGERPRINT,
"plugin.build", "Plugin binary does not match this Editor SDK build");
for (const auto& value : module->panels) {
const auto name = value.at("command").get<std::string>();
require(std::any_of(module->commands.begin(), module->commands.end(),
[&](const auto& reg) { return reg.descriptor.at("name") == name; }),
"plugin.panel_command", "Panel command must be registered by its owner");
}
for (const auto& registration : module->commands) {
const auto& descriptor = registration.descriptor;
const auto name = descriptor.at("name").get<std::string>();
commands.add(
name, descriptor.at("description"), descriptor.at("inputSchema"),
[registration](const Json& arguments) {
std::string output;
const auto input = arguments.dump();
const int result =
registration.callback(registration.user, input.c_str(), append, &output);
const auto value = response(output);
if (result != 0)
throw Error(value.value("code", std::string("plugin.failed")),
value.value("message", std::string("Plugin command failed")),
value);
return value;
},
descriptor.value("read_only", false));
module->installed.push_back(name);
}
module->ready = true;
logger("Loaded editor plugin: " + module->id());
modules.push_back(std::move(module));
}
};
PluginManager::PluginManager(Commands& commands, Logger logger)
: impl_(std::make_unique<Impl>(commands, std::move(logger))) {}
PluginManager::~PluginManager() = default;
std::string PluginManager::fingerprint() {
return FASET_EDITOR_SDK_FINGERPRINT;
}
Json PluginManager::status() const {
return impl_->records;
}
Json PluginManager::panels() const {
Json result = Json::array();
for (const auto& module : impl_->modules)
for (const auto& panel : module->panels)
result.push_back(panel);
return result;
}
void PluginManager::load(const std::filesystem::path& directory) {
require(!impl_->attempted, "plugin.restart_required",
"Plugin discovery runs once; restart the Editor after changing packages");
impl_->attempted = true;
if (!std::filesystem::exists(directory))
return;
struct Source {
Json manifest;
std::filesystem::path directory;
};
std::map<std::string, Source> sources;
std::set<std::string> invalid;
auto failure = [&](const std::string& id, const std::string& message) {
invalid.insert(id);
impl_->records.push_back({{"id", id}, {"state", "failed"}, {"message", message}});
impl_->logger("Plugin " + id + ": " + message);
};
for (const auto& entry : std::filesystem::recursive_directory_iterator(directory))
if (entry.is_regular_file() &&
entry.path().filename().string().ends_with(".faset-plugin.json")) {
std::string id = entry.path().filename().string();
try {
const auto manifest = read_json(entry.path());
id = manifest.at("id");
require(identifier(id), "plugin.id", "Invalid module ID");
require(!sources.contains(id), "plugin.duplicate", "Duplicate module ID");
require(manifest.at("format") == "faset.editor_plugin" &&
manifest.at("version") == 1 && manifest.at("kind") == "editor",
"plugin.manifest", "Unsupported editor plugin manifest");
require(manifest.at("module_version").is_string() &&
manifest.at("dependencies").is_array(),
"plugin.manifest", "Plugin needs version and dependency list");
require(manifest.at("api_version") == FASET_EDITOR_API_VERSION &&
manifest.at("build_fingerprint") == fingerprint(),
"plugin.compatibility",
"Plugin manifest does not match this Editor SDK; rebuild it");
project_path(entry.path().parent_path(), manifest.at("library").get<std::string>());
sources.emplace(id, Source{manifest, entry.path().parent_path()});
} catch (const std::exception& error) {
failure(id, error.what());
}
}
std::map<std::string, int> colors;
std::vector<std::string> order;
std::function<void(const std::string&)> visit = [&](const std::string& id) {
require(sources.contains(id) && !invalid.contains(id), "plugin.dependency",
"Missing or invalid dependency: " + id);
require(colors[id] != 1, "plugin.cycle", "Plugin dependency cycle at " + id);
if (colors[id] == 2)
return;
colors[id] = 1;
for (const auto& dependency : sources.at(id).manifest.at("dependencies")) {
const std::string required = dependency.at("id");
visit(required);
require(sources.at(required).manifest.at("module_version") == dependency.at("version"),
"plugin.dependency_version", "Dependency version mismatch: " + required);
}
colors[id] = 2;
order.push_back(id);
};
for (const auto& [id, source] : sources)
try {
visit(id);
} catch (const std::exception& error) {
failure(id, error.what());
}
std::set<std::string> loaded;
for (const auto& id : order)
if (!invalid.contains(id))
try {
const auto& source = sources.at(id);
for (const auto& dependency : source.manifest.at("dependencies"))
require(loaded.contains(dependency.at("id").get<std::string>()),
"plugin.dependency_failed", "A required plugin failed to load");
impl_->activate(source.manifest, source.directory);
loaded.insert(id);
impl_->records.push_back({{"id", id},
{"version", source.manifest.at("module_version")},
{"state", "loaded"}});
} catch (const std::exception& error) {
failure(id, error.what());
}
}
} // namespace faset::editor
+358
View File
@@ -0,0 +1,358 @@
#include <algorithm>
#include <chrono>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
namespace faset::editor {
namespace {
std::string executable_name(const std::string& name) {
#ifdef _WIN32
return name + ".exe";
#else
return name;
#endif
}
BuildConfig build_config(const SessionConfig& config) {
BuildConfig result;
result.project_root = config.project_root;
result.engine_root = config.engine_root;
result.build_directory = config.project_root / ".faset/build";
result.cache_root = config.project_root / ".faset/cache";
return result;
}
Json resolved_or_throw(Commands& commands, const std::string& id) {
const auto resolved = commands.resolved_scene(id);
if (!resolved.at("conflicts").empty())
throw Error("template.conflicts", "Resolve template conflicts before Play or export",
{{"conflicts", resolved.at("conflicts")}});
return resolved.at("scene");
}
} // namespace
struct Session::ImportTask {
std::string id;
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
mutable std::mutex mutex;
std::string state = "queued", error;
Json result = Json::object();
Json json() const {
std::lock_guard lock(mutex);
const auto progress = job->progress();
return {{"id", id},
{"kind", "import"},
{"state", state},
{"stage", progress.stage},
{"progress", progress.fraction},
{"error", error},
{"result", result}};
}
};
Session::Session(SessionConfig config)
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
register_commands();
plugins_ = std::make_unique<PluginManager>(
commands_, [this](std::string message) { log(std::move(message)); });
plugins_->load(config_.project_root / "Plugins");
const auto schema = config_.project_root / ".faset/schema.json";
if (std::filesystem::exists(schema))
try {
load_schema(schema);
} catch (const std::exception& error) {
log(std::string("Schema load failed: ") + error.what());
}
}
Session::~Session() {
for (const auto& [id, task] : imports_)
task->job->cancel();
workers_.clear();
stop_player();
}
void Session::log(std::string value) {
if (value.empty())
return;
logs_.push_back(std::move(value));
if (logs_.size() > 1000)
logs_.erase(logs_.begin(), logs_.begin() + 100);
}
Json Session::project() const {
const auto path = config_.project_root / "project.faset.json";
if (std::filesystem::exists(path))
return read_json(path);
return {{"format", "faset.project"},
{"version", 1},
{"name", config_.project_root.filename().string()},
{"dimension", 3}};
}
void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension);
log("Created project: " + name);
}
Json Session::assets_list() const {
Json list = Json::array();
const auto directory = assets_.cache_root() / "assets";
if (std::filesystem::exists(directory))
for (const auto& entry : std::filesystem::directory_iterator(directory))
if (entry.is_directory()) {
try {
const auto id = entry.path().filename().string();
const auto manifest = assets_.current_manifest(id);
list.push_back({{"id", id}, {"manifest", manifest}});
} catch (const std::exception& error) {
list.push_back(
{{"id", entry.path().filename().string()}, {"error", error.what()}});
}
}
return {{"assets", list}};
}
Json Session::jobs() const {
Json list = Json::array();
for (const auto& item : builds_.jobs())
list.push_back(item.json());
for (const auto& [id, task] : imports_)
list.push_back(task->json());
return {{"jobs", list}};
}
Json Session::job(const std::string& id) const {
if (imports_.contains(id))
return imports_.at(id)->json();
return builds_.job(id).json();
}
void Session::load_schema(const std::filesystem::path& path) {
const auto value = read_json(path);
authoring_.replace_external_schemas(value);
const auto output = config_.project_root / ".faset/schema.json";
if (std::filesystem::weakly_canonical(path) != std::filesystem::weakly_canonical(output))
atomic_write_json(output, value);
log("Gameplay schema loaded");
}
void Session::launch_player(Json scene, const std::filesystem::path& executable) {
require(std::filesystem::is_regular_file(executable), "play.missing_player",
"Build the Player before starting Play");
const auto directory = config_.project_root / ".faset/play" / new_id();
std::filesystem::create_directories(directory);
const auto snapshot = directory / "scene.fscene";
write_cooked_scene(snapshot, scene);
control_path_ = directory / "control.json";
control_sequence_ = 0;
ProcessOptions options;
options.arguments = {
executable.string(), "--scene", snapshot.string(), "--assets",
assets_.cache_root().string(), "--control", control_path_.string()};
options.working_directory = config_.project_root;
player_ = std::make_unique<Process>(options);
log("Play started in a separate Player process");
}
void Session::stop_player() {
if (!pending_play_job_.empty()) {
builds_.cancel(pending_play_job_);
pending_play_job_.clear();
pending_play_scene_ = nullptr;
}
if (player_) {
player_->cancel();
const auto result = player_->poll();
log(result.output);
player_.reset();
log("Play stopped; authoring scene unchanged");
}
}
void Session::poll() {
if (player_) {
const auto result = player_->poll();
log(result.output);
if (!result.running) {
log("Player exited with code " + std::to_string(result.exit_code.value_or(-1)));
player_.reset();
}
}
for (const auto& value : builds_.jobs()) {
if (observed_jobs_[value.id] == value.state)
continue;
observed_jobs_[value.id] = value.state;
if (value.state == "failed")
log(value.kind + " failed: " + value.error);
if (value.state == "succeeded") {
log(value.kind + " completed");
if (value.result.contains("schema"))
try {
load_schema(value.result.at("schema").get<std::string>());
} catch (const std::exception& error) {
log(std::string("Schema update failed: ") + error.what());
}
}
if (value.id == pending_play_job_ && value.finished()) {
pending_play_job_.clear();
if (value.state == "succeeded")
try {
const auto executable =
value.result.value("player", (builds_.config().build_directory /
executable_name("faset_player"))
.string());
launch_player(pending_play_scene_, executable);
} catch (const std::exception& error) {
log(std::string("Play failed: ") + error.what());
}
pending_play_scene_ = nullptr;
}
}
for (const auto& [id, task] : imports_) {
const auto value = task->json();
const auto status = value.at("state").get<std::string>();
if (observed_jobs_[id] == status)
continue;
observed_jobs_[id] = status;
if (status == "succeeded")
log("Asset import completed: " + value["result"].value("asset_id", std::string()));
if (status == "failed" || status == "conflict")
log("Asset import " + status + ": " + value.value("error", std::string()));
}
}
void Session::register_commands() {
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
auto schema = [](Json properties, Json required = Json::array()) {
return Commands::object_schema(std::move(properties), std::move(required));
};
commands_.add(
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
schema(Json::object()),
[&](const Json&) {
bool screenshot = false;
for (const auto& command : commands_.list())
if (command.at("name") == "faset_editor_capture")
screenshot = true;
return Json{{"authoring", true},
{"build", true},
{"import", true},
{"plugins", true},
{"viewport_capture", screenshot},
{"runtime_entity_access", false},
{"protocol", "2025-06-18"}};
},
true);
commands_.add(
"faset_plugins", "Inspect startup-loaded Editor plugins and exact SDK compatibility.",
schema(Json::object()),
[&](const Json&) {
return Json{{"sdk_fingerprint", PluginManager::fingerprint()},
{"plugins", plugins_->status()},
{"panels", plugins_->panels()}};
},
true);
commands_.add(
"faset_project", "Read the authoring project's settings.", schema(Json::object()),
[&](const Json&) { return project(); }, true);
commands_.add(
"faset_assets", "List imported asset manifests and resource identities.",
schema(Json::object()), [&](const Json&) { return assets_list(); }, true);
commands_.add("faset_import",
"Import GLB/glTF or a Blender export manifest relative to this project. Returns "
"a cancellable job ID; failure retains the last successful generation.",
schema({{"path", text},
{"settings", {{"type", "object"}}},
{"allow_removed_outputs", boolean}},
{"path"}),
[&](const Json& args) {
assets::ImportRequest request;
request.source =
project_path(config_.project_root, args.at("path").get<std::string>());
request.settings = args.value("settings", Json(nullptr));
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
auto task = std::make_shared<ImportTask>();
task->id = "import-" + new_id();
imports_[task->id] = task;
workers_.emplace_back([this, task, request] {
{
std::lock_guard lock(task->mutex);
task->state = "running";
}
try {
const auto result = assets_.import_asset(request, *task->job);
std::lock_guard lock(task->mutex);
task->state =
result.status == assets::ImportStatus::succeeded ? "succeeded"
: result.status == assets::ImportStatus::cancelled ? "cancelled"
: result.status == assets::ImportStatus::conflict ? "conflict"
: "failed";
task->result = {{"asset_id", result.asset_id},
{"generation", result.generation},
{"diagnostics", result.diagnostics},
{"cache_hit", result.cache_hit},
{"manifest", result.manifest}};
for (const auto& message : result.diagnostics)
task->error += message + "\n";
} catch (const std::exception& error) {
std::lock_guard lock(task->mutex);
task->state = "failed";
task->error = error.what();
}
});
return Json{{"job", task->id}};
});
commands_.add("faset_build",
"Incrementally compile C++ gameplay and export its metadata in separate native "
"processes. Returns a job ID.",
schema(Json::object()),
[&](const Json&) { return Json{{"job", builds_.start_build()}}; });
commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.",
schema({{"document", text}, {"output", text}}, {"document", "output"}),
[&](const Json& args) {
return Json{{"job", builds_.start_export(
resolved_or_throw(commands_, args.at("document")),
project_path(config_.project_root,
args.at("output").get<std::string>()))}};
});
commands_.add(
"faset_jobs", "List editor import/build/export jobs and their progress.",
schema(Json::object()), [&](const Json&) { return jobs(); }, true);
commands_.add(
"faset_job", "Read an editor job's progress, result and diagnostics.",
schema({{"id", text}}, {"id"}), [&](const Json& args) { return job(args.at("id")); }, true);
commands_.add("faset_job_cancel",
"Cancel an editor job. Cancellation is separate from authoring Undo.",
schema({{"id", text}}, {"id"}), [&](const Json& args) {
const auto id = args.at("id").get<std::string>();
if (imports_.contains(id))
imports_.at(id)->job->cancel();
else
builds_.cancel(id);
return Json{{"cancel_requested", true}};
});
commands_.add("faset_play",
"Build gameplay, then start a separate Player from an immutable snapshot of the "
"current authoring document. Returns the build job ID.",
schema({{"document", text}}, {"document"}), [&](const Json& args) {
stop_player();
pending_play_scene_ = resolved_or_throw(commands_, args.at("document"));
pending_play_job_ = builds_.start_build();
return Json{{"job", pending_play_job_}, {"play_pending", true}};
});
commands_.add(
"faset_stop",
"Stop editor Play or cancel its pending build. Does not modify the authoring scene.",
schema(Json::object()), [&](const Json&) {
stop_player();
return Json{{"stopped", true}};
});
commands_.add("faset_play_control",
"Pause, resume, or single-step the Editor's Player session. No game entities or "
"state are exposed.",
schema({{"command", {{"type", "string"}, {"enum", {"pause", "resume", "step"}}}}},
{"command"}),
[&](const Json& args) {
require(bool(player_), "play.not_running", "Player is not running");
const auto command = args.at("command").get<std::string>();
require(command == "pause" || command == "resume" || command == "step",
"play.command", "Unknown Play control");
atomic_write_json(control_path_,
{{"sequence", ++control_sequence_}, {"command", command}});
return Json{{"queued", true}};
});
commands_.add(
"faset_editor_logs",
"Read compiler, importer and process diagnostics collected by this Editor session.",
schema(Json::object()), [&](const Json&) { return Json{{"logs", logs_}}; }, true);
}
} // namespace faset::editor
+378
View File
@@ -0,0 +1,378 @@
#include <algorithm>
#include <cmath>
#include <faset/assets/asset_data.hpp>
#include <faset/player/SceneView.hpp>
#include <limits>
#include <numbers>
#include <set>
#include <stdexcept>
#include <unordered_map>
#if defined(FASET_HAS_STB)
#define STB_IMAGE_IMPLEMENTATION
#define STBI_NO_STDIO
#include <stb_image.h>
#endif
namespace faset::player {
namespace {
using Json = nlohmann::json;
Json properties(const Json& entity, const std::string& name) {
if (entity.contains(name))
return entity.at(name);
if (entity.contains("components"))
for (const auto& c : entity["components"])
if (c.at("type") == "faset." + name)
return c.at("fields");
return Json{};
}
template <std::size_t N>
std::array<float, N> vec(const Json& value, const char* name, std::array<float, N> fallback) {
if (value.is_null() || !value.contains(name))
return fallback;
auto result = value.at(name).get<std::array<float, N>>();
for (float v : result)
if (!std::isfinite(v))
throw std::runtime_error("nonfinite scene vector");
return result;
}
render::Vec3 point(const render::Mat4& m, render::Vec3 p) {
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]};
}
render::Vec3 direction(const render::Mat4& m, render::Vec3 p) {
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2]};
}
std::pair<std::string, std::string> reference(const std::string& ref) {
const auto hash = ref.find('#');
return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)};
}
std::shared_ptr<const render::Mesh> plane() {
static const auto mesh = []() {
auto out = std::make_shared<render::Mesh>();
out->vertices = {{{-.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 0}},
{{.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 0}},
{{.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 1}},
{{-.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 1}}};
out->indices = {0, 2, 1, 0, 3, 2};
return out;
}();
return mesh;
}
} // namespace
struct SceneView::Impl {
struct Bundle {
assets::CookedAsset data;
std::vector<std::vector<std::shared_ptr<const render::Mesh>>> meshes;
std::vector<std::shared_ptr<const render::Texture>> textures;
};
assets::AssetStore pipeline;
std::unordered_map<std::string, Bundle> bundles;
std::vector<std::string> messages;
explicit Impl(std::filesystem::path path) : pipeline(std::move(path)) {}
Bundle& bundle(const std::string& id) {
if (auto it = bundles.find(id); it != bundles.end())
return it->second;
Bundle result;
result.data = pipeline.load_asset(id);
for (const auto& mesh : result.data.meshes) {
auto& primitives = result.meshes.emplace_back();
for (const auto& primitive : mesh.primitives) {
auto converted = std::make_shared<render::Mesh>();
converted->indices = primitive.indices;
converted->vertices.reserve(primitive.vertices.size());
for (const auto& vertex : primitive.vertices)
converted->vertices.push_back(
{vertex.position, vertex.normal, {1, 1, 1, 1}, vertex.uv});
primitives.push_back(std::move(converted));
}
}
for (const auto& texture : result.data.textures) {
std::shared_ptr<render::Texture> converted;
#if defined(FASET_HAS_STB)
if (texture.bytes.size() > std::size_t(std::numeric_limits<int>::max()))
throw std::runtime_error("Encoded texture exceeds decoder limit");
int width = 0, height = 0, channels = 0;
if (!stbi_info_from_memory(reinterpret_cast<const unsigned char*>(texture.bytes.data()),
static_cast<int>(texture.bytes.size()), &width, &height,
&channels))
throw std::runtime_error("Cannot read texture dimensions: " + texture.id);
if (width <= 0 || height <= 0 || width > 16384 || height > 16384 ||
std::uint64_t(width) * std::uint64_t(height) > 64 * 1024 * 1024)
throw std::runtime_error("Texture exceeds decoder image limits");
auto pixels = stbi_load_from_memory(
reinterpret_cast<const unsigned char*>(texture.bytes.data()),
static_cast<int>(texture.bytes.size()), &width, &height, &channels, 4);
if (!pixels)
throw std::runtime_error("Cannot decode texture " + texture.id + ": " +
std::string(stbi_failure_reason() ? stbi_failure_reason()
: "unsupported image"));
std::unique_ptr<unsigned char, decltype(&stbi_image_free)> guard(pixels,
&stbi_image_free);
if (width <= 0 || height <= 0 || width > 16384 || height > 16384)
throw std::runtime_error("Texture exceeds 16384 dimension limit");
converted = std::make_shared<render::Texture>();
converted->width = width;
converted->height = height;
converted->srgb = true;
converted->rgba.assign(pixels, pixels + std::size_t(width) * std::size_t(height) * 4);
#else
throw std::runtime_error("Image decoding was not enabled for this Player build");
#endif
result.textures.push_back(std::move(converted));
}
return bundles.emplace(id, std::move(result)).first->second;
}
std::shared_ptr<const render::Texture> texture(const std::string& ref) {
auto [id, selector] = reference(ref);
auto& asset = bundle(id);
if (asset.textures.empty())
throw std::runtime_error("Asset has no texture: " + ref);
if (selector.empty())
return asset.textures.front();
for (std::size_t i = 0; i < asset.data.textures.size(); ++i)
if (asset.data.textures[i].id == selector)
return asset.textures[i];
throw std::runtime_error("Texture subasset does not exist: " + ref);
}
void imported(render::Snapshot& out, const std::string& ref, const render::Mat4& model,
render::Color tint) {
const auto [id, selector] = reference(ref);
auto& asset = bundle(id);
auto emit = [&](std::size_t meshIndex, const render::Mat4& local) {
if (meshIndex >= asset.meshes.size())
throw std::runtime_error("Invalid cooked mesh index");
for (std::size_t p = 0; p < asset.meshes[meshIndex].size(); ++p) {
render::DrawItem draw;
draw.mesh = asset.meshes[meshIndex][p];
draw.model = render::multiply(model, local);
draw.color = tint;
const auto material = asset.data.meshes[meshIndex].primitives[p].material;
if (material >= 0) {
if (std::size_t(material) >= asset.data.materials.size())
throw std::runtime_error("Invalid cooked material index");
const auto& m = asset.data.materials[material];
for (int i = 0; i < 4; ++i)
draw.color[i] *= m.base_color[i];
draw.roughness = m.roughness;
draw.metallic = m.metallic;
if (m.base_color_texture >= 0) {
if (std::size_t(m.base_color_texture) >= asset.textures.size())
throw std::runtime_error("Invalid base-color texture index");
draw.texture = asset.textures[m.base_color_texture];
}
if (m.normal_texture >= 0 || m.metallic_roughness_texture >= 0 ||
m.alpha_mode != "OPAQUE" || m.unlit || m.double_sided)
messages.push_back(
"warning: material " + m.id +
" has features beyond the initial base-color/PBR renderer");
}
out.draws.push_back(std::move(draw));
}
};
if (!selector.empty())
for (std::size_t i = 0; i < asset.data.meshes.size(); ++i)
if (asset.data.meshes[i].id == selector) {
emit(i, render::identity);
return;
}
std::unordered_map<std::string, const assets::Node*> nodes;
for (const auto& node : asset.data.nodes)
nodes.emplace(node.id, &node);
if (!selector.empty() && !nodes.contains(selector))
throw std::runtime_error("Node/mesh subasset does not exist: " + ref);
std::unordered_map<std::string, render::Mat4> matrices;
std::set<std::string> active;
auto world = [&](auto&& self, const assets::Node& node) -> render::Mat4 {
if (auto it = matrices.find(node.id); it != matrices.end())
return it->second;
if (!active.insert(node.id).second)
throw std::runtime_error("Cyclic cooked node hierarchy");
auto matrix = node.local_transform;
if (!node.parent_id.empty()) {
auto parent = nodes.find(node.parent_id);
if (parent == nodes.end())
throw std::runtime_error("Missing cooked parent node");
matrix = render::multiply(self(self, *parent->second), matrix);
}
active.erase(node.id);
return matrices.emplace(node.id, matrix).first->second;
};
if (asset.data.nodes.empty())
for (std::size_t i = 0; i < asset.meshes.size(); ++i)
emit(i, render::identity);
for (const auto& node : asset.data.nodes)
if (node.mesh >= 0) {
bool selected = selector.empty();
auto current = &node;
std::set<std::string> seen;
while (!selected && current && seen.insert(current->id).second) {
selected = current->id == selector;
auto parent = nodes.find(current->parent_id);
current = parent == nodes.end() ? nullptr : parent->second;
}
if (selected)
emit(static_cast<std::size_t>(node.mesh), world(world, node));
}
}
};
SceneView::SceneView(std::filesystem::path cacheRoot)
: impl_(std::make_unique<Impl>(std::move(cacheRoot))) {}
SceneView::~SceneView() = default;
void SceneView::clearCache() {
impl_->bundles.clear();
}
const std::vector<std::string>& SceneView::diagnostics() const {
return impl_->messages;
}
render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSettings camera) {
if (!std::isfinite(aspect) || aspect <= 0)
throw std::invalid_argument("Viewport aspect must be positive");
impl_->messages.clear();
render::Snapshot out;
const auto& entities = scene.at("entities");
if (!entities.is_array())
throw std::invalid_argument("Scene entities must be an array");
std::unordered_map<std::string, const Json*> byId;
for (const auto& entity : entities)
if (!byId.emplace(entity.at("id").get<std::string>(), &entity).second)
throw std::invalid_argument("Duplicate scene ID");
std::unordered_map<std::string, render::Mat4> matrices;
std::set<std::string> active;
auto world = [&](auto&& self, const Json& entity) -> render::Mat4 {
auto id = entity.at("id").get<std::string>();
if (auto it = matrices.find(id); it != matrices.end())
return it->second;
if (!active.insert(id).second)
throw std::invalid_argument("Cyclic scene hierarchy");
const auto fields = properties(entity, "transform");
auto matrix = render::transform(vec<3>(fields, "position", {0, 0, 0}),
vec<3>(fields, "rotation", {0, 0, 0}),
vec<3>(fields, "scale", {1, 1, 1}));
if (entity.contains("parent") && !entity["parent"].is_null()) {
auto parent = byId.find(entity["parent"].get<std::string>());
if (parent == byId.end())
throw std::invalid_argument("Missing scene parent");
matrix = render::multiply(self(self, *parent->second), matrix);
}
active.erase(id);
return matrices.emplace(id, matrix).first->second;
};
const int dimension = scene.value("dimension", 3);
if (dimension != 2 && dimension != 3)
throw std::invalid_argument("Scene dimension must be 2 or 3");
render::Vec3 cameraUp{0, 1, 0};
bool foundCamera = false;
std::vector<std::pair<int, render::Sprite>> sprites;
for (const auto& entity : entities) {
const auto model = world(world, entity);
if (auto fields = properties(entity, "camera");
!fields.is_null() && !camera.overrideSceneCamera && !foundCamera) {
camera.eye = point(model, {0, 0, 0});
camera.target = point(model, {0, 0, -1});
cameraUp = direction(model, {0, 1, 0});
camera.verticalFovDegrees = fields.value("fov", 60.0f);
camera.nearPlane = fields.value("near", 0.1f);
camera.farPlane = fields.value("far", 1000.0f);
foundCamera = true;
}
if (auto fields = properties(entity, "light"); !fields.is_null())
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
render::Sprite sprite;
sprite.position = point(model, {0, 0, 0});
auto size = vec<2>(fields, "size", {1, 1});
float sx = std::hypot(model[0], model[1]), sy = std::hypot(model[4], model[5]);
sprite.size = {size[0] * sx, size[1] * sy};
sprite.rotation = std::atan2(model[1], model[0]);
sprite.color = vec<4>(fields, "color", {1, 1, 1, 1});
if (model[0] * model[5] - model[1] * model[4] < 0)
sprite.size[1] = -sprite.size[1];
if (sx > 0 && sy > 0 &&
std::abs((model[0] * model[4] + model[1] * model[5]) / (sx * sy)) > 0.0001f)
impl_->messages.push_back("warning: sprite hierarchy shear is approximated");
auto texture = fields.value("texture", std::string{});
if (!texture.empty())
try {
sprite.texture = impl_->texture(texture);
} catch (const std::exception& e) {
impl_->messages.push_back("error: " + std::string(e.what()));
sprite.color = {1, 0, 1, 1};
}
sprites.emplace_back(fields.value("layer", 0), std::move(sprite));
}
if (auto fields = properties(entity, "mesh"); !fields.is_null()) {
const auto tint = vec<4>(fields, "color", {1, 1, 1, 1});
const auto asset = fields.value("asset", std::string{});
if (asset.empty() || asset.starts_with("builtin:")) {
const auto primitive = asset.empty()
? fields.value("primitive", std::string("cube"))
: asset.substr(8);
if (primitive != "plane" && primitive != "cube")
throw std::invalid_argument("Unsupported builtin mesh: " + primitive);
out.draws.push_back({primitive == "plane" ? plane() : render::cube_mesh(),
model,
tint,
0.65f,
0.0f,
true,
{}});
} else
try {
impl_->imported(out, asset, model, tint);
} catch (const std::exception& e) {
impl_->messages.push_back("error: " + std::string(e.what()));
out.draws.push_back(
{render::cube_mesh(), model, {1, 0, 1, 1}, 0.65f, 0.0f, true, {}});
}
}
}
std::stable_sort(sprites.begin(), sprites.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& pair : sprites)
out.sprites.push_back(std::move(pair.second));
if (dimension == 2) {
const float height = camera.orthographicHeight;
if (!std::isfinite(height) || height <= 0)
throw std::invalid_argument("Orthographic height must be positive");
const auto center = foundCamera ? camera.eye : camera.target;
out.view_projection =
render::multiply(render::orthographic(-height * aspect / 2, height * aspect / 2,
-height / 2, height / 2, -100, 100),
render::transform({-center[0], -center[1], 0}));
out.eye = {center[0], center[1], 10};
} else {
if (!std::isfinite(camera.verticalFovDegrees) || camera.verticalFovDegrees <= 0 ||
camera.verticalFovDegrees >= 179)
throw std::invalid_argument("Camera FOV out of range");
if (!std::isfinite(camera.nearPlane) || !std::isfinite(camera.farPlane) ||
camera.nearPlane <= 0 || camera.farPlane <= camera.nearPlane)
throw std::invalid_argument("Camera depth range is invalid");
float distance = 0;
render::Vec3 delta{};
for (int i = 0; i < 3; ++i) {
if (!std::isfinite(camera.eye[i]) || !std::isfinite(camera.target[i]) ||
!std::isfinite(cameraUp[i]))
throw std::invalid_argument("Camera basis must be finite");
delta[i] = camera.target[i] - camera.eye[i];
distance += delta[i] * delta[i];
}
const render::Vec3 cross{delta[1] * cameraUp[2] - delta[2] * cameraUp[1],
delta[2] * cameraUp[0] - delta[0] * cameraUp[2],
delta[0] * cameraUp[1] - delta[1] * cameraUp[0]};
if (distance < 1e-10f ||
cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] < 1e-10f)
throw std::invalid_argument("Camera basis is degenerate");
out.eye = camera.eye;
out.view_projection = render::multiply(
render::perspective(camera.verticalFovDegrees * std::numbers::pi_v<float> / 180, aspect,
camera.nearPlane, camera.farPlane),
render::look_at(camera.eye, camera.target, cameraUp));
}
std::sort(impl_->messages.begin(), impl_->messages.end());
impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()),
impl_->messages.end());
return out;
}
} // namespace faset::player
+32
View File
@@ -0,0 +1,32 @@
#include <cstring>
#include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp>
#include <stdexcept>
namespace faset::player {
nlohmann::json readScene(const std::filesystem::path& path) {
if (!std::filesystem::is_regular_file(path))
throw std::runtime_error("Scene file does not exist: " + path.string());
if (std::filesystem::file_size(path) > 256 * 1024 * 1024)
throw std::runtime_error("Scene exceeds the 256 MiB reader limit");
const auto bytes = faset::read_text(path);
if (bytes.size() >= 8 && std::memcmp(bytes.data(), "FASETSCN", 8) == 0) {
if (bytes.size() < 20)
throw std::runtime_error("Truncated cooked scene header");
std::uint32_t version = 0;
std::uint64_t size = 0;
for (int i = 0; i < 4; ++i)
version |= std::uint32_t(static_cast<unsigned char>(bytes[8 + i])) << (8 * i);
for (int i = 0; i < 8; ++i)
size |= std::uint64_t(static_cast<unsigned char>(bytes[12 + i])) << (8 * i);
if (version != 1)
throw std::runtime_error("Unsupported cooked scene version");
if (size != bytes.size() - 20)
throw std::runtime_error("Cooked scene payload size mismatch");
return nlohmann::json::from_cbor(bytes.begin() + 20, bytes.end(), true, true);
}
if (path.extension() == ".fscene")
throw std::runtime_error("Cooked scene magic is invalid");
return nlohmann::json::parse(bytes);
}
} // namespace faset::player
+80 -19
View File
@@ -1,26 +1,87 @@
#include <faset/render/renderer.hpp>
#include <cmath> #include <cmath>
#include <faset/render/renderer.hpp>
#include <stdexcept> #include <stdexcept>
namespace faset::render { namespace faset::render {
namespace { namespace {
Vec3 sub(Vec3 a, Vec3 b) { return {a[0]-b[0],a[1]-b[1],a[2]-b[2]}; } Vec3 sub(Vec3 a, Vec3 b) {
float dot(Vec3 a,Vec3 b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];} return {a[0] - b[0], a[1] - b[1], a[2] - b[2]};
Vec3 cross(Vec3 a,Vec3 b){return {a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]};}
Vec3 unit(Vec3 a){float l=std::sqrt(dot(a,a)); if(l<1e-6f) throw std::invalid_argument("Degenerate camera axis"); return {a[0]/l,a[1]/l,a[2]/l};}
} }
Mat4 multiply(const Mat4& a,const Mat4& b){Mat4 r{};for(int c=0;c<4;++c)for(int y=0;y<4;++y)for(int k=0;k<4;++k)r[c*4+y]+=a[k*4+y]*b[c*4+k];return r;} float dot(Vec3 a, Vec3 b) {
Mat4 transform(Vec3 p,Vec3 r,Vec3 s){ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const float cx=std::cos(r[0]),sx=std::sin(r[0]),cy=std::cos(r[1]),sy=std::sin(r[1]),cz=std::cos(r[2]),sz=std::sin(r[2]);
Mat4 x{1,0,0,0,0,cx,sx,0,0,-sx,cx,0,0,0,0,1};
Mat4 y{cy,0,-sy,0,0,1,0,0,sy,0,cy,0,0,0,0,1};
Mat4 z{cz,sz,0,0,-sz,cz,0,0,0,0,1,0,0,0,0,1};
auto m=multiply(z,multiply(y,x));for(int c=0;c<3;++c)for(int i=0;i<3;++i)m[c*4+i]*=s[c];m[12]=p[0];m[13]=p[1];m[14]=p[2];return m;
} }
Mat4 perspective(float fov,float aspect,float n,float f){if(aspect<=0||n<=0||f<=n)throw std::invalid_argument("Invalid perspective volume");float q=1/std::tan(fov*.5f);return {q/aspect,0,0,0,0,-q,0,0,0,0,f/(n-f),-1,0,0,n*f/(n-f),0};} Vec3 cross(Vec3 a, Vec3 b) {
Mat4 orthographic(float l,float r,float b,float t,float n,float f){if(r==l||t==b||f==n)throw std::invalid_argument("Invalid orthographic volume");return {2/(r-l),0,0,0,0,-2/(t-b),0,0,0,0,1/(n-f),0,-(r+l)/(r-l),(t+b)/(t-b),n/(n-f),1};} return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]};
Mat4 look_at(Vec3 e,Vec3 t,Vec3 up){auto f=unit(sub(t,e));auto s=unit(cross(f,up));auto u=cross(s,f);return {s[0],u[0],-f[0],0,s[1],u[1],-f[1],0,s[2],u[2],-f[2],0,-dot(s,e),-dot(u,e),dot(f,e),1};}
std::shared_ptr<const Mesh> cube_mesh(){static auto mesh=[](){auto m=std::make_shared<Mesh>();
const Vec3 normals[]={{0,0,1},{0,0,-1},{1,0,0},{-1,0,0},{0,1,0},{0,-1,0}};
const Vec3 points[][4]={{{-.5f,-.5f,.5f},{.5f,-.5f,.5f},{.5f,.5f,.5f},{-.5f,.5f,.5f}},{{.5f,-.5f,-.5f},{-.5f,-.5f,-.5f},{-.5f,.5f,-.5f},{.5f,.5f,-.5f}},{{.5f,-.5f,.5f},{.5f,-.5f,-.5f},{.5f,.5f,-.5f},{.5f,.5f,.5f}},{{-.5f,-.5f,-.5f},{-.5f,-.5f,.5f},{-.5f,.5f,.5f},{-.5f,.5f,-.5f}},{{-.5f,.5f,.5f},{.5f,.5f,.5f},{.5f,.5f,-.5f},{-.5f,.5f,-.5f}},{{-.5f,-.5f,-.5f},{.5f,-.5f,-.5f},{.5f,-.5f,.5f},{-.5f,-.5f,.5f}}};
for(int face=0;face<6;++face){for(auto p:points[face])m->vertices.push_back({p,normals[face],{1,1,1,1}});for(auto i:{0u,1u,2u,0u,2u,3u})m->indices.push_back(face*4+i);}return m;}();return mesh;}
} }
Vec3 unit(Vec3 a) {
float l = std::sqrt(dot(a, a));
if (l < 1e-6f)
throw std::invalid_argument("Degenerate camera axis");
return {a[0] / l, a[1] / l, a[2] / l};
}
} // namespace
Mat4 multiply(const Mat4& a, const Mat4& b) {
Mat4 r{};
for (int c = 0; c < 4; ++c)
for (int y = 0; y < 4; ++y)
for (int k = 0; k < 4; ++k)
r[c * 4 + y] += a[k * 4 + y] * b[c * 4 + k];
return r;
}
Mat4 transform(Vec3 p, Vec3 r, Vec3 s) {
const float cx = std::cos(r[0]), sx = std::sin(r[0]), cy = std::cos(r[1]), sy = std::sin(r[1]),
cz = std::cos(r[2]), sz = std::sin(r[2]);
Mat4 x{1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1};
Mat4 y{cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1};
Mat4 z{cz, sz, 0, 0, -sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
auto m = multiply(z, multiply(y, x));
for (int c = 0; c < 3; ++c)
for (int i = 0; i < 3; ++i)
m[c * 4 + i] *= s[c];
m[12] = p[0];
m[13] = p[1];
m[14] = p[2];
return m;
}
Mat4 perspective(float fov, float aspect, float n, float f) {
if (aspect <= 0 || n <= 0 || f <= n)
throw std::invalid_argument("Invalid perspective volume");
float q = 1 / std::tan(fov * .5f);
return {q / aspect, 0, 0, 0, 0, -q, 0, 0, 0, 0, f / (n - f), -1, 0, 0, n * f / (n - f), 0};
}
Mat4 orthographic(float l, float r, float b, float t, float n, float f) {
if (r == l || t == b || f == n)
throw std::invalid_argument("Invalid orthographic volume");
return {2 / (r - l), 0, 0, 0, 0, -2 / (t - b), 0,
0, 0, 0, 1 / (n - f), 0, -(r + l) / (r - l), (t + b) / (t - b),
n / (n - f), 1};
}
Mat4 look_at(Vec3 e, Vec3 t, Vec3 up) {
auto f = unit(sub(t, e));
auto s = unit(cross(f, up));
auto u = cross(s, f);
return {s[0], u[0], -f[0], 0, s[1], u[1], -f[1], 0,
s[2], u[2], -f[2], 0, -dot(s, e), -dot(u, e), dot(f, e), 1};
}
std::shared_ptr<const Mesh> cube_mesh() {
static auto mesh = []() {
auto m = std::make_shared<Mesh>();
const Vec3 normals[] = {{0, 0, 1}, {0, 0, -1}, {1, 0, 0},
{-1, 0, 0}, {0, 1, 0}, {0, -1, 0}};
const Vec3 points[][4] = {
{{-.5f, -.5f, .5f}, {.5f, -.5f, .5f}, {.5f, .5f, .5f}, {-.5f, .5f, .5f}},
{{.5f, -.5f, -.5f}, {-.5f, -.5f, -.5f}, {-.5f, .5f, -.5f}, {.5f, .5f, -.5f}},
{{.5f, -.5f, .5f}, {.5f, -.5f, -.5f}, {.5f, .5f, -.5f}, {.5f, .5f, .5f}},
{{-.5f, -.5f, -.5f}, {-.5f, -.5f, .5f}, {-.5f, .5f, .5f}, {-.5f, .5f, -.5f}},
{{-.5f, .5f, .5f}, {.5f, .5f, .5f}, {.5f, .5f, -.5f}, {-.5f, .5f, -.5f}},
{{-.5f, -.5f, -.5f}, {.5f, -.5f, -.5f}, {.5f, -.5f, .5f}, {-.5f, -.5f, .5f}}};
for (int face = 0; face < 6; ++face) {
for (auto p : points[face])
m->vertices.push_back({p, normals[face], {1, 1, 1, 1}});
for (auto i : {0u, 1u, 2u, 0u, 2u, 3u})
m->indices.push_back(face * 4 + i);
}
return m;
}();
return mesh;
}
} // namespace faset::render
+21 -10
View File
@@ -3,25 +3,36 @@
#include <unordered_set> #include <unordered_set>
#include <utility> #include <utility>
namespace faset::render { namespace faset::render {
void RenderGraph::import(std::string resource) { imports_.push_back(std::move(resource)); } void RenderGraph::import(std::string resource) {
void RenderGraph::add(std::string name, std::vector<std::string> reads, std::vector<std::string> writes, Callback execute) { imports_.push_back(std::move(resource));
if (name.empty() || !execute) throw std::invalid_argument("RenderGraph pass requires a name and callback"); }
for (const auto& pass : passes_) if (pass.name == name) throw std::invalid_argument("Duplicate RenderGraph pass: " + name); void RenderGraph::add(std::string name, std::vector<std::string> reads,
passes_.push_back({std::move(name),std::move(reads),std::move(writes),std::move(execute)}); std::vector<std::string> writes, Callback execute) {
if (name.empty() || !execute)
throw std::invalid_argument("RenderGraph pass requires a name and callback");
for (const auto& pass : passes_)
if (pass.name == name)
throw std::invalid_argument("Duplicate RenderGraph pass: " + name);
passes_.push_back({std::move(name), std::move(reads), std::move(writes), std::move(execute)});
} }
void RenderGraph::execute() const { void RenderGraph::execute() const {
std::unordered_set<std::string> available(imports_.begin(), imports_.end()); std::unordered_set<std::string> available(imports_.begin(), imports_.end());
// Validate the whole graph before recording any GPU work. // Validate the whole graph before recording any GPU work.
for (const auto& pass : passes_) { for (const auto& pass : passes_) {
for (const auto& resource : pass.reads) for (const auto& resource : pass.reads)
if (!available.contains(resource)) throw std::runtime_error("RenderGraph pass '" + pass.name + "' reads uninitialized resource '" + resource + "'"); if (!available.contains(resource))
for (const auto& resource : pass.writes) available.insert(resource); throw std::runtime_error("RenderGraph pass '" + pass.name +
"' reads uninitialized resource '" + resource + "'");
for (const auto& resource : pass.writes)
available.insert(resource);
} }
for (const auto& pass : passes_) pass.callback(); for (const auto& pass : passes_)
pass.callback();
} }
std::vector<std::string> RenderGraph::pass_names() const { std::vector<std::string> RenderGraph::pass_names() const {
std::vector<std::string> result; std::vector<std::string> result;
for (const auto& pass : passes_) result.push_back(pass.name); for (const auto& pass : passes_)
result.push_back(pass.name);
return result; return result;
} }
} } // namespace faset::render

Some files were not shown because too many files have changed in this diff Show More