Add optional Lua scripting module, examples, and validation
This commit is contained in:
@@ -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{};
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user