Add optional Lua scripting module, examples, and validation

This commit is contained in:
emil28092005
2026-09-18 11:28:50 +03:00
parent 06210aac23
commit 5a67735ff4
45 changed files with 3887 additions and 92 deletions
+103
View File
@@ -103,6 +103,109 @@ std::filesystem::path find_executable(const std::string& name) {
return resolve_program(name, path ? path : "", std::filesystem::current_path());
#endif
}
void launch_detached(const std::vector<std::string>& arguments,
const std::filesystem::path& working_directory) {
if (arguments.empty() || arguments.front().empty())
throw std::invalid_argument("External application requires an executable");
for (const auto& argument : arguments)
if (argument.find('\0') != std::string::npos)
throw std::invalid_argument("NUL in external application argument");
const auto cwd = working_directory.empty() ? std::filesystem::current_path()
: std::filesystem::absolute(working_directory);
if (!std::filesystem::is_directory(cwd))
throw std::runtime_error("External application working directory does not exist");
#ifdef _WIN32
auto program = std::filesystem::path(widen(arguments.front()));
if (program.has_parent_path() && program.is_relative())
program = cwd / program;
const auto executable =
program.has_parent_path() ? program : find_executable(arguments.front());
std::wstring command;
for (const auto& argument : arguments) {
if (!command.empty())
command += L' ';
command += quote(widen(argument));
}
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
PROCESS_INFORMATION process{};
if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE,
CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, nullptr, cwd.c_str(), &startup,
&process))
throw std::runtime_error("Cannot launch external application: " + arguments.front());
// Deliberately no kill-on-close job: the user's editor must outlive this Editor session.
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
#else
const char* path = std::getenv("PATH");
const auto executable = resolve_program(arguments.front(), path ? path : "", cwd);
std::vector<char*> argv;
for (const auto& argument : arguments)
argv.push_back(const_cast<char*>(argument.c_str()));
argv.push_back(nullptr);
int errors[2];
if (::pipe2(errors, O_CLOEXEC) < 0)
throw std::runtime_error("Cannot create external application status pipe");
// A host may have closed a standard stream. Keep the status pipe out of dup2's targets.
for (auto& descriptor : errors)
if (descriptor <= STDERR_FILENO) {
const auto replacement = ::fcntl(descriptor, F_DUPFD_CLOEXEC, STDERR_FILENO + 1);
if (replacement < 0) {
::close(errors[0]);
::close(errors[1]);
throw std::runtime_error("Cannot configure external application status pipe");
}
::close(descriptor);
descriptor = replacement;
}
const auto child = ::fork();
if (child == 0) {
// After fork in the multi-threaded Editor, only async-signal-safe calls are allowed.
::close(errors[0]);
auto fail = [&](int error) {
while (::write(errors[1], &error, sizeof(error)) < 0 && errno == EINTR) {
}
::_exit(127);
};
if (::setsid() < 0)
fail(errno);
const auto grandchild = ::fork();
if (grandchild < 0)
fail(errno);
if (grandchild > 0)
::_exit(0);
const auto input = ::open("/dev/null", O_RDWR);
if (input < 0)
fail(errno);
if (::dup2(input, STDIN_FILENO) < 0 || ::dup2(input, STDOUT_FILENO) < 0 ||
::dup2(input, STDERR_FILENO) < 0 || ::chdir(cwd.c_str()) < 0)
fail(errno);
if (input > STDERR_FILENO)
::close(input);
::execve(executable.c_str(), argv.data(), environ);
fail(errno);
}
::close(errors[1]);
if (child < 0) {
::close(errors[0]);
throw std::runtime_error("Cannot fork external application");
}
int status{};
pid_t reaped;
do {
reaped = ::waitpid(child, &status, 0);
} while (reaped < 0 && errno == EINTR);
int error{};
ssize_t count;
do {
count = ::read(errors[0], &error, sizeof(error));
} while (count < 0 && errno == EINTR);
::close(errors[0]);
if (count != 0 || reaped < 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0)
throw std::runtime_error("Cannot launch external application: " + arguments.front() +
(count > 0 ? ": " + std::string(std::strerror(error)) : ""));
#endif
}
struct Process::Impl {
#ifdef _WIN32
HANDLE process{}, thread{}, job{}, output{};
+69 -12
View File
@@ -10,6 +10,7 @@
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <fstream>
#include <functional>
#include <map>
@@ -114,6 +115,7 @@ struct BuildService::Impl {
std::condition_variable finished;
Json scene;
Json asset_manifests = Json::object();
scripting::LuaProject lua;
fs::path output;
};
BuildConfig config;
@@ -240,11 +242,16 @@ struct BuildService::Impl {
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");
checkpoint(job, "Configuring gameplay", .05);
job.lua = scripting::loadLuaProject(config.project_root);
const auto cpp = config.project_root / "Scripts" / "Gameplay.cpp";
const auto hpp = config.project_root / "Scripts" / "Gameplay.hpp";
const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp);
if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled()))
throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, "
"or Lua entry scripts declared in project.faset.json");
const auto cpp_source = has_cpp ? read_text(cpp) : std::string{};
const auto hpp_source = has_hpp ? read_text(hpp) : std::string{};
fs::create_directories(native_directory);
std::vector<std::string> arguments = {config.cmake,
"-S",
@@ -278,6 +285,10 @@ struct BuildService::Impl {
arguments.insert(arguments.end(), config.configure_arguments.begin(),
config.configure_arguments.end());
arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
// Project declarations, not a stale cache or a user-supplied override, determine
// whether the packaged game has a Lua VM linked into it.
arguments.push_back(std::string("-DFASET_ENABLE_LUA=") +
(job.lua.enabled() ? "ON" : "OFF"));
run(job, std::move(arguments), config.project_root);
checkpoint(job, "Compiling and linking Player", .25);
run(job,
@@ -292,8 +303,18 @@ struct BuildService::Impl {
fs::create_directories(staging);
try {
const auto schema_file = staging / "schema.json";
run(job, {path_to_utf8(exporter), "--output", path_to_utf8(schema_file)},
config.project_root);
std::vector<std::string> export_arguments = {path_to_utf8(exporter), "--output",
path_to_utf8(schema_file)};
if (job.lua.enabled()) {
scripting::writeLuaSources(job.lua, staging);
atomic_write_json(staging / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"scripting", {{"lua", {{"scripts", job.lua.scripts}}}}}});
export_arguments.insert(export_arguments.end(),
{"--project", path_to_utf8(staging)});
}
run(job, std::move(export_arguments), config.project_root);
auto schema = read_json(schema_file);
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
!schema.contains("types") || !schema.at("types").is_array())
@@ -303,10 +324,11 @@ struct BuildService::Impl {
(void)authoring::gameplay_schemas(schema);
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 += cpp_source + hpp_source + job.lua.fingerprint;
fingerprint = sha256(fingerprint);
schema["build_fingerprint"] = fingerprint;
if (job.lua.enabled())
schema["lua_fingerprint"] = job.lua.fingerprint;
atomic_write_json(schema_file, schema);
copy_required_file(player, staging / ("faset_player" + executable_suffix()));
copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix()));
@@ -320,10 +342,19 @@ struct BuildService::Impl {
{"id", job.status.id},
{"fingerprint", fingerprint},
{"configuration", configuration},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68);
if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot changed during schema export");
if (scripting::loadLuaProject(config.project_root).fingerprint != job.lua.fingerprint ||
has_cpp != fs::is_regular_file(cpp) || has_hpp != fs::is_regular_file(hpp) ||
(has_cpp && (read_text(cpp) != cpp_source || read_text(hpp) != hpp_source)))
throw std::runtime_error("Gameplay sources changed during the build; build again");
fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
@@ -333,6 +364,8 @@ struct BuildService::Impl {
{"configuration", configuration},
{"player", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(generation / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint}};
} catch (...) {
std::error_code error;
@@ -442,7 +475,8 @@ struct BuildService::Impl {
throw;
}
}
void package_notices(const fs::path& destination, const fs::path& native_directory) {
void package_notices(const fs::path& destination, const fs::path& native_directory,
bool lua_enabled) {
fs::create_directories(destination);
auto lock = read_json(config.engine_root / "dependencies.lock.json");
const std::vector<std::string> runtime_dependencies = {"sdl3", "entt", "box2d",
@@ -476,6 +510,11 @@ struct BuildService::Impl {
throw std::runtime_error("Cannot package required license notices for " + name);
used[name] = lock.at("dependencies").at(name);
}
if (lua_enabled) {
copy_required_file(config.engine_root / "docs" / "licenses" / "Lua.txt",
destination / "lua" / "LICENSE.txt");
used["lua"] = lock.at("dependencies").at("lua");
}
atomic_write_json(destination / "dependencies.json", used);
if (fs::is_regular_file(config.engine_root / "LICENSE"))
copy_required_file(config.engine_root / "LICENSE", destination / "Faset-LICENSE");
@@ -529,6 +568,16 @@ struct BuildService::Impl {
checkpoint(job, "Cooking export snapshot", .72);
write_cooked_scene(staging / "scene.fscene", job.scene);
auto build_directory = path_from_utf8(built.at("directory").get<std::string>());
if (job.lua.enabled()) {
// Never read live Scripts files for a published game: schemas, source,
// and fingerprint all originate in the same validated build snapshot.
const auto captured = scripting::loadLuaProject(build_directory);
if (captured.fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot is corrupt");
scripting::writeLuaSources(captured, staging);
copy_required_file(build_directory / "project.faset.json",
staging / "project.faset.json");
}
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
@@ -546,12 +595,15 @@ struct BuildService::Impl {
checkpoint(job, "Packaging assets and notices", .80);
package_assets(job, staging);
package_notices(staging / "Notices",
path_from_utf8(built.at("build_directory").get<std::string>()));
path_from_utf8(built.at("build_directory").get<std::string>()),
job.lua.enabled());
atomic_write(
staging / "README.txt",
"Run faset_player" + executable_suffix() +
" to start this game.\nThe executable loads scene.fscene and assets beside "
"it.\nKeep shaders/, assets/, and Notices/ with the executable.\nA compatible "
"it.\nKeep shaders/, assets/, Notices/, and any Scripts/ and "
"project.faset.json "
"with the executable.\nA compatible "
"Vulkan 1.3 driver and the supported OS runtime are required.\n");
#ifdef _WIN32
atomic_write(staging / "Windows-Runtime.txt",
@@ -567,6 +619,9 @@ struct BuildService::Impl {
"--scene", path_to_utf8(staging / "scene.fscene"), "--assets",
path_to_utf8(staging)},
staging);
if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Packaged Lua snapshot changed during validation");
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
if (entry.is_symlink())
@@ -581,6 +636,8 @@ struct BuildService::Impl {
{"version", 1},
{"generation", job.status.id},
{"build_fingerprint", built.at("fingerprint")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"scene_hash", sha256(job.scene.dump())},
{"asset_generations", Json::object()},
{"configuration", built.at("configuration")},
+29 -8
View File
@@ -180,6 +180,8 @@ struct EditorUI::Impl {
std::string attempted_theme, attempted_layout, applied_theme, applied_layout,
presentation_error;
std::chrono::steady_clock::time_point last_presentation_poll{};
std::chrono::steady_clock::time_point last_schema_poll{};
Json schema_state;
bool simulation_open = false;
bool project_settings_open = false;
Json project_settings_state;
@@ -435,7 +437,7 @@ struct EditorUI::Impl {
button(
toolbar, "step", "Step", [this] { call("faset_play_control", {{"command", "step"}}); },
55);
button(toolbar, "build", "Build C++", [this] { call("faset_build"); }, 94);
button(toolbar, "build", "Build", [this] { call("faset_build"); }, 94);
button(
toolbar, "export", "Export",
[this] { call("faset_export", {{"document", document}, {"output", "Exports"}}); }, 64);
@@ -510,7 +512,7 @@ struct EditorUI::Impl {
assetbar.layout.height = 30;
assetbar.layout.padding = 2;
assetbar.layout.gap = 5;
label(assetbar, "asset-path", "Project assets", 135);
label(assetbar, "asset-path", "Project files", 135);
auto& search = assetbar.add(Kind::TextField, "asset-search", "");
search.layout.width = 220;
search.on_preview = [this](Widget& w) {
@@ -1093,6 +1095,12 @@ struct EditorUI::Impl {
void open_source() {
if (source_file.empty())
return;
if (path_from_utf8(source_file).extension() == ".lua") {
const auto result = call("faset_script_open", {{"path", source_file}});
if (!result.is_null())
status = "Opened in external editor: " + source_file;
return;
}
auto result = call("faset_document_open", {{"path", source_file}});
if (!result.is_null())
choose_document(result.at("id"));
@@ -1167,15 +1175,20 @@ struct EditorUI::Impl {
ui.find("pause")->enabled = session.playing();
ui.find("step")->enabled = session.playing();
ui.find("pause")->text = paused ? "Resume" : "Pause";
const auto schema_state = call("faset_schema_status");
const auto now = std::chrono::steady_clock::now();
// Source fingerprints read complete script bytes; do not hash them every render frame.
if (schema_state.is_null() || now - last_schema_poll >= std::chrono::seconds(1)) {
schema_state = call("faset_schema_status");
last_schema_poll = now;
}
if (!schema_state.is_null()) {
const bool stale = schema_state.value("stale", false);
ui.find("build")->text = stale ? "Build C++ !" : "Build C++";
ui.find("build")->text = stale ? "Build !" : "Build";
ui.find("build")->tooltip =
stale ? "Gameplay schema is stale: " + schema_state.value("error", std::string())
: "Build gameplay and refresh metadata";
: "Incremental gameplay build and C++/Lua Inspector metadata refresh";
if (stale && status == "Ready")
status = "Gameplay schema is stale; Build C++ to refresh";
status = "Gameplay schema is stale; Build to refresh Inspector metadata";
}
ui.find("project-title")->text = session.project().value("name", std::string("Project")) +
@@ -1604,7 +1617,7 @@ struct EditorUI::Impl {
last_assets = now;
files = Json::array();
try {
for (const std::string folder : {"Assets", "Scenes"}) {
for (const std::string folder : {"Assets", "Scenes", "Scripts"}) {
const auto directory = session.config().project_root / folder;
if (!std::filesystem::exists(directory))
continue;
@@ -1615,6 +1628,8 @@ struct EditorUI::Impl {
break;
if (!entry.is_regular_file())
continue;
if (folder == "Scripts" && entry.path().extension() != ".lua")
continue;
auto relative = generic_path_to_utf8(
std::filesystem::relative(entry.path(), session.config().project_root));
if (relative.find(".faset-") != std::string::npos)
@@ -1636,6 +1651,11 @@ struct EditorUI::Impl {
assets = result.at("assets");
}
auto& list = *ui.find("asset-items");
const bool script = path_from_utf8(source_file).extension() == ".lua";
ui.find("asset-open")->text = script ? "Open Script" : "Open Scene";
ui.find("asset-open")->tooltip =
script ? "Open Lua source in your external editor" : "Open a project scene";
ui.find("asset-import")->enabled = !script;
std::set<std::string> keep;
for (const auto& file : files) {
const auto path = file.get<std::string>();
@@ -1683,7 +1703,8 @@ struct EditorUI::Impl {
keep.insert(row.id);
}
if (keep.empty()) {
label(list, "assets-empty", "Place GLB, glTF, PNG or JPEG in Assets, then Import.");
label(list, "assets-empty",
"Import images/models from Assets, or open Lua sources from Scripts.");
keep.insert("assets-empty");
}
trim_children(list, keep);
+109 -7
View File
@@ -3,6 +3,7 @@
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
#include <faset/scripting/project.hpp>
namespace faset::editor {
namespace {
@@ -130,24 +131,32 @@ Json Session::assets_list() const {
return {{"assets", list}};
}
std::string Session::source_signature() const {
const auto lua = scripting::loadLuaProject(config_.project_root);
const auto directory = config_.project_root / "Scripts";
std::vector<std::filesystem::path> files;
if (std::filesystem::exists(directory))
for (const auto& file : std::filesystem::recursive_directory_iterator(directory))
if (file.is_regular_file())
if (file.is_regular_file() && (!lua.enabled() || file.path().extension() != ".lua"))
files.push_back(file.path());
std::sort(files.begin(), files.end());
std::string contents;
for (const auto& file : files)
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
sha256_file(file) + "\n";
if (lua.enabled())
contents += "lua:" + lua.fingerprint + "\n";
return sha256(contents);
}
Json Session::schema_status() const {
return {{"loaded", schema_loaded_},
{"stale", !schema_loaded_ || schema_source_signature_ != source_signature() ||
!schema_error_.empty()},
{"error", schema_error_}};
try {
const auto signature = source_signature();
return {{"loaded", schema_loaded_},
{"stale", !schema_loaded_ || schema_source_signature_ != signature ||
!schema_error_.empty()},
{"error", schema_error_}};
} catch (const std::exception& error) {
return {{"loaded", schema_loaded_}, {"stale", true}, {"error", error.what()}};
}
}
Json Session::jobs() const {
Json list = Json::array();
@@ -185,9 +194,14 @@ void Session::launch_player(Json scene, const std::filesystem::path& executable)
options.arguments = {
path_to_utf8(executable), "--scene", path_to_utf8(snapshot), "--assets",
path_to_utf8(assets_.cache_root()), "--control", path_to_utf8(control_path_)};
if (scripting::loadLuaProject(config_.project_root).enabled()) {
options.arguments.insert(options.arguments.end(),
{"--project", path_to_utf8(config_.project_root), "--watch-lua"});
}
options.working_directory = config_.project_root;
player_ = std::make_unique<Process>(options);
log("Play started in a separate Player process");
log("Play started in a separate Player process; Lua projects reload changed scripts and reset "
"simulation state automatically");
}
void Session::stop_player() {
if (!pending_play_job_.empty()) {
@@ -439,7 +453,7 @@ void Session::register_commands() {
"Failed builds retain metadata but mark it stale.",
schema(Json::object()), [&](const Json&) { return schema_status(); }, true);
commands_.add("faset_build",
"Incrementally compile C++ gameplay and export its metadata in separate native "
"Incrementally compile gameplay and export C++/Lua metadata in separate native "
"processes. Returns a job ID.",
schema(Json::object()), [&](const Json&) {
const auto signature = source_signature();
@@ -447,6 +461,94 @@ void Session::register_commands() {
submitted_sources_[id] = signature;
return Json{{"job", id}};
});
commands_.add(
"faset_lua_refresh",
"Validate Lua behavior schemas in a separate process and refresh Inspector metadata. "
"Uses the incremental build; script edits do not require C++ compilation. Returns a job "
"ID.",
schema(Json::object()), [&](const Json&) {
require(scripting::loadLuaProject(config_.project_root).enabled(), "lua.disabled",
"Declare scripting.lua.scripts in project.faset.json first");
const auto signature = source_signature();
const auto id = builds_.start_build();
submitted_sources_[id] = signature;
return Json{{"job", id}};
});
commands_.add("faset_lua_reload",
"Reload Lua in the running development Player. Successful reload resets the Play "
"snapshot and script state; invalid edits keep the current running version.",
schema(Json::object()), [&](const Json&) {
require(bool(player_), "play.not_running", "Player is not running");
require(scripting::loadLuaProject(config_.project_root).enabled(),
"lua.disabled", "The current project has no Lua behaviors");
atomic_write_json(control_path_, {{"sequence", ++control_sequence_},
{"command", "reload-lua"}});
return Json{{"queued", true}};
});
commands_.add(
"faset_lua_setup",
"Install Faset LuaLS type annotations in .faset/lua and create .luarc.json only if absent. "
"Existing user language-server configuration is never overwritten.",
schema(Json::object()), [&](const Json&) {
const auto source = config_.engine_root / "tools/lua";
const auto annotations = project_path(config_.project_root, ".faset/lua/faset.lua");
const auto configuration = project_path(config_.project_root, ".luarc.json");
atomic_write(annotations, read_text(source / "faset.lua"));
const bool create_configuration = !std::filesystem::exists(configuration);
if (create_configuration)
atomic_write_json(configuration, read_json(source / "luarc.json"));
log(create_configuration
? "LuaLS configured: .luarc.json and .faset/lua/faset.lua"
: "LuaLS annotations updated; existing .luarc.json preserved. Add .faset/lua "
"to workspace.library if needed");
return Json{{"annotations", ".faset/lua/faset.lua"},
{"configuration_created", create_configuration}};
});
commands_.add(
"faset_script_open",
"Open a project Lua source in an external editor, never as an executable. Optional editor "
"is an argv array (default: project editor.script_editor, then zed). Exact {file} and "
"{project} arguments are replaced; no shell expansion is performed.",
schema({{"path", text}, {"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}},
{"path"}),
[&](const Json& args) {
const auto file = project_path(config_.project_root,
path_from_utf8(args.at("path").get<std::string>()));
require(file.extension() == ".lua" && std::filesystem::is_regular_file(file),
"lua.source", "Select an existing .lua file inside the project");
Json command = Json::array({"zed", "{file}"});
const auto settings = project();
if (settings.contains("editor") && settings.at("editor").is_object() &&
settings.at("editor").contains("script_editor"))
command = settings.at("editor").at("script_editor");
if (args.contains("editor"))
command = args.at("editor");
require(command.is_array() && !command.empty(), "lua.editor",
"Configure editor.script_editor as a nonempty executable/argument array");
std::vector<std::string> arguments;
bool has_file = false;
for (const auto& part : command) {
require(part.is_string(), "lua.editor", "Editor arguments must be strings");
auto argument = part.get<std::string>();
require(argument.find('\0') == std::string::npos, "lua.editor",
"Editor arguments cannot contain NUL");
if (argument == "{file}") {
require(!arguments.empty(), "lua.editor", "The first argument is the editor");
argument = path_to_utf8(file);
has_file = true;
} else if (argument == "{project}") {
require(!arguments.empty(), "lua.editor", "The first argument is the editor");
argument = path_to_utf8(std::filesystem::absolute(config_.project_root));
}
arguments.push_back(std::move(argument));
}
require(!arguments.front().empty(), "lua.editor", "Choose an editor executable");
if (!has_file)
arguments.push_back(path_to_utf8(file));
launch_detached(arguments, config_.project_root);
log("Opened Lua source in external editor: " + args.at("path").get<std::string>());
return Json{{"opened", args.at("path")}};
});
commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
#include "Gameplay.hpp"
namespace faset::gameplay {
void registerGameplay(runtime::Runtime&) {}
nlohmann::json schema() {
return nlohmann::json::array();
}
} // namespace faset::gameplay
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& runtime);
nlohmann::json schema();
} // namespace faset::gameplay
+162
View File
@@ -0,0 +1,162 @@
#include <faset/scripting/project.hpp>
#include <array>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <fstream>
#include <set>
#include <stdexcept>
namespace faset::scripting {
namespace fs = std::filesystem;
namespace {
constexpr std::size_t max_source_bytes = 1024 * 1024;
constexpr std::size_t max_total_bytes = 16 * 1024 * 1024;
constexpr std::size_t max_sources = 4096;
constexpr std::size_t max_directory_entries = 16384;
fs::path source_path(const std::string& name) {
if (name.size() > 1024 || name.find('\0') != std::string::npos ||
name.find('\\') != std::string::npos || name.find(':') != std::string::npos)
throw std::runtime_error("Lua source path must use project-relative forward slashes: " +
name);
const auto path = path_from_utf8(name);
if (path.is_absolute() || path.has_root_path() || path.empty() || *path.begin() != "Scripts" ||
path.extension() != ".lua" || generic_path_to_utf8(path.lexically_normal()) != name)
throw std::runtime_error("Lua sources must be normalized .lua paths below Scripts/: " +
name);
for (const auto& part : path)
if (part == "." || part == ".." || part.empty())
throw std::runtime_error("Lua source path contains traversal: " + name);
return path;
}
void no_symlinks(const fs::path& root, const fs::path& relative = {}) {
auto current = root;
if (fs::is_symlink(fs::symlink_status(current)))
throw std::runtime_error("Lua project root must not be a symlink");
for (const auto& part : relative) {
current /= part;
if (fs::is_symlink(fs::symlink_status(current)))
throw std::runtime_error("Lua project paths must not contain symlinks: " +
path_to_utf8(current));
}
}
std::string bounded_text(const fs::path& path) {
if (!fs::is_regular_file(path) || fs::file_size(path) > max_source_bytes)
throw std::runtime_error("Lua source or manifest must be a regular file at most 1 MiB: " +
path_to_utf8(path));
std::ifstream stream(native_io_path(path), std::ios::binary);
if (!stream)
throw std::runtime_error("Cannot read Lua project file: " + path_to_utf8(path));
std::string value;
std::array<char, 16384> buffer{};
while (stream) {
stream.read(buffer.data(), buffer.size());
const auto count = static_cast<std::size_t>(stream.gcount());
if (value.size() + count > max_source_bytes)
throw std::runtime_error("Lua project file exceeds 1 MiB: " + path_to_utf8(path));
value.append(buffer.data(), count);
}
if (stream.bad())
throw std::runtime_error("Cannot read Lua project file: " + path_to_utf8(path));
return value;
}
void validate_snapshot(const LuaProject& project) {
if (project.scripts.size() > max_sources || project.sources.size() > max_sources)
throw std::runtime_error("Lua project exceeds 4096 source files");
std::set<std::string> entries;
for (const auto& name : project.scripts) {
(void)source_path(name);
if (!entries.insert(name).second || !project.sources.contains(name))
throw std::runtime_error("Duplicate or missing Lua entry source: " + name);
}
std::size_t bytes{};
for (const auto& [name, source] : project.sources) {
(void)source_path(name);
bytes += source.size();
if (source.size() > max_source_bytes || bytes > max_total_bytes)
throw std::runtime_error(
"Lua project exceeds source size limits (1 MiB/file, 16 MiB total)");
}
}
} // namespace
LuaProject loadLuaProject(const fs::path& projectRoot) {
LuaProject result;
no_symlinks(projectRoot);
const auto manifest = projectRoot / "project.faset.json";
no_symlinks(projectRoot, "project.faset.json");
if (!fs::exists(manifest))
return result;
const auto project = Json::parse(bounded_text(manifest));
if (!project.is_object())
throw std::runtime_error("Lua project manifest must be an object");
if (!project.contains("scripting"))
return result;
const auto& scripting = project.at("scripting");
if (!scripting.is_object())
throw std::runtime_error("Project scripting must be an object");
if (!scripting.contains("lua"))
return result;
const auto& lua = scripting.at("lua");
if (!lua.is_object() || !lua.contains("scripts") || !lua.at("scripts").is_array())
throw std::runtime_error("Project scripting.lua.scripts must be an array of entry paths");
if (lua.at("scripts").size() > max_sources)
throw std::runtime_error("Lua project exceeds 4096 entry scripts");
std::set<std::string> unique;
for (const auto& entry : lua.at("scripts")) {
if (!entry.is_string())
throw std::runtime_error("Lua entry paths must be strings");
auto name = entry.get<std::string>();
const auto path = source_path(name);
no_symlinks(projectRoot, path);
if (!unique.insert(name).second)
throw std::runtime_error("Duplicate Lua entry source: " + name);
if (!fs::is_regular_file(projectRoot / path))
throw std::runtime_error("Missing Lua entry source: " + name);
result.scripts.push_back(std::move(name));
}
if (!result.enabled())
return result;
no_symlinks(projectRoot, "Scripts");
std::size_t count{}, total{};
for (auto it = fs::recursive_directory_iterator(projectRoot / "Scripts");
it != fs::recursive_directory_iterator(); ++it) {
if (++count > max_directory_entries || it.depth() > 32)
throw std::runtime_error("Lua Scripts directory exceeds traversal limits");
if (it->is_symlink())
throw std::runtime_error("Lua Scripts directory contains a symlink: " +
path_to_utf8(it->path()));
if (it->path().extension() != ".lua")
continue;
const auto name = generic_path_to_utf8(it->path().lexically_relative(projectRoot));
(void)source_path(name);
auto source = bounded_text(it->path());
total += source.size();
if (total > max_total_bytes || result.sources.size() >= max_sources)
throw std::runtime_error(
"Lua project exceeds source limits (4096 files, 16 MiB total)");
result.sources.emplace(name, std::move(source));
}
validate_snapshot(result);
// JSON supplies unambiguous framing; std::map makes source ordering stable.
result.fingerprint = sha256(Json{
{"format", "faset.lua-sources.v1"},
{"scripts", result.scripts},
{"sources", result.sources}}.dump());
return result;
}
void writeLuaSources(const LuaProject& project, const fs::path& targetRoot) {
validate_snapshot(project);
no_symlinks(targetRoot);
// Validate all destinations before writing any source.
for (const auto& [name, source] : project.sources)
no_symlinks(targetRoot, source_path(name));
for (const auto& [name, source] : project.sources)
atomic_write(targetRoot / source_path(name), source);
}
} // namespace faset::scripting