Add optional Lua scripting module, examples, and validation
This commit is contained in:
@@ -68,6 +68,12 @@ jobs:
|
||||
- name: Test Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: ctest --preset linux-debug
|
||||
- name: Verify Lua-free native build
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
cmake -S . -B build/no-lua -G Ninja -DCMAKE_BUILD_TYPE=Debug -DFASET_ENABLE_LUA=OFF -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF
|
||||
cmake --build build/no-lua --target faset_schema_exporter faset_runtime_tests --parallel 2
|
||||
ctest --test-dir build/no-lua --output-on-failure -R '^(runtime_contracts|lua_cli_contracts)$'
|
||||
- name: Test Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: ctest --preset windows-debug -LE gpu
|
||||
|
||||
@@ -10,6 +10,7 @@ option(FASET_BUILD_RENDERER "Build the SDL3/Vulkan renderer and graphical applic
|
||||
option(FASET_SANITIZERS "Enable address and undefined behavior sanitizers" OFF)
|
||||
option(FASET_DEBUG_IMGUI "Build optional Dear ImGui diagnostics library" OFF)
|
||||
option(FASET_BUILD_RUNTIME "Build ECS and physics runtime" ON)
|
||||
option(FASET_ENABLE_LUA "Build the optional sandboxed Lua scripting module" ON)
|
||||
option(FASET_BUILD_ASSETS "Build asset import tools" ON)
|
||||
option(FASET_BUILD_AUTHORING "Build scene authoring and metadata" ON)
|
||||
option(FASET_BUILD_EDITOR "Build retained UI and editor applications" ON)
|
||||
@@ -32,6 +33,12 @@ target_include_directories(faset_core PUBLIC include)
|
||||
target_link_libraries(faset_core PUBLIC nlohmann_json::nlohmann_json Threads::Threads)
|
||||
target_compile_definitions(faset_core PUBLIC FASET_VERSION="${PROJECT_VERSION}")
|
||||
|
||||
# Manifest/snapshot support is independent of the Lua VM and available to tooling
|
||||
# even when the selected game is C++-only.
|
||||
add_library(faset_scripting_project STATIC src/scripting/project.cpp)
|
||||
target_include_directories(faset_scripting_project PUBLIC include)
|
||||
target_link_libraries(faset_scripting_project PUBLIC faset_core)
|
||||
|
||||
# Modules are independent targets; Player never links authoring, editor or MCP.
|
||||
foreach(module Authoring Runtime Assets)
|
||||
if(module STREQUAL "Authoring" AND NOT FASET_BUILD_AUTHORING)
|
||||
@@ -47,6 +54,9 @@ foreach(module Authoring Runtime Assets)
|
||||
include(cmake/${module}.cmake)
|
||||
endif()
|
||||
endforeach()
|
||||
if(FASET_ENABLE_LUA AND TARGET faset_runtime)
|
||||
include(cmake/Lua.cmake)
|
||||
endif()
|
||||
if(FASET_BUILD_RENDERER AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Renderer.cmake")
|
||||
include(cmake/Renderer.cmake)
|
||||
endif()
|
||||
|
||||
@@ -29,7 +29,7 @@ This README is in English. The current planning documents, studies, and research
|
||||
|
||||
## Accepted foundation
|
||||
|
||||
- **C++** for the core and the first gameplay implementation. **Lua** will follow as a separate module and will be optional for individual games.
|
||||
- **C++** for the core and native gameplay. **Lua 5.4** is an optional sandboxed gameplay module, with Inspector schemas, development reload, and standalone export. See the [Lua guide](docs/manual/scripting/lua.md).
|
||||
- Objects, components, and nested scene templates for authoring; **EnTT** for the runtime ECS. JSON authoring data, stable IDs, and cooked binary assets for export.
|
||||
- A custom **Vulkan 1.3** backend, RenderGraph, and renderer. The backend calls Vulkan directly; gameplay uses Faset APIs. **Slang** compiles shaders, including compatible HLSL, to SPIR-V. The baseline renderer does not require ray tracing.
|
||||
- **SDL3** behind Faset's platform API; **Box2D** and **Box3D** for physics.
|
||||
@@ -39,7 +39,7 @@ This README is in English. The current planning documents, studies, and research
|
||||
- **Editor-only MCP:** authoring, assets, import, builds, export, Play/Stop, and editor diagnostics. MCP is absent from the Player and exported games.
|
||||
- Standard, **unmodified Blender**, glTF/GLB import, and an optional add-on for convenient export and stable IDs.
|
||||
|
||||
The MVP provides two small games, one 2D and one 3D, with scene editing, C++ behavior, physics, Play and standalone export. Lua, GPU-driven rendering, HZB, advanced shadows, temporal reconstruction and dynamic global illumination follow this baseline.
|
||||
The MVP provides two small games, one 2D and one 3D, with scene editing, C++ behavior, physics, Play and standalone export. A [Lua-only example](examples/lua) demonstrates the optional scripting module. GPU-driven rendering, HZB, advanced shadows, temporal reconstruction and dynamic global illumination follow this baseline.
|
||||
|
||||
## Run the research map
|
||||
|
||||
|
||||
+157
-24
@@ -7,8 +7,13 @@
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/runtime/schema.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#if defined(FASET_HAS_LUA)
|
||||
#include <faset/scripting/LuaModule.hpp>
|
||||
#endif
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
@@ -177,16 +182,19 @@ void validatePackagedShaders(const std::filesystem::path& directory) {
|
||||
int player_main(int argc, char** argv) {
|
||||
const auto started = Clock::now();
|
||||
try {
|
||||
std::filesystem::path scenePath, assetsPath, capturePath, controlPath, profilePath;
|
||||
bool headless = false, validateOnly = false, debugPhysics = false;
|
||||
std::filesystem::path scenePath, assetsPath, capturePath, controlPath, profilePath,
|
||||
projectRoot;
|
||||
bool headless = false, validateOnly = false, debugPhysics = false, watchLua = 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] "
|
||||
std::cout
|
||||
<< "faset_player [--scene PATH] [--assets CACHE] [--frames N] "
|
||||
"[--headless] [--capture PATH.ppm] [--validate] [--control PATH] "
|
||||
"[--profile PATH.json] [--debug-physics]\n"
|
||||
"[--profile PATH.json] [--debug-physics] [--project ROOT] "
|
||||
"[--watch-lua]\n"
|
||||
"No --scene: open scene.fscene beside the executable. CACHE contains "
|
||||
"assets/<id>/.\n"
|
||||
"Headless uses offscreen Vulkan; --frames uses the configured fixed "
|
||||
@@ -195,6 +203,10 @@ int player_main(int argc, char** argv) {
|
||||
"or Vulkan initialization.\n"
|
||||
"--control is an optional editor mailbox for pause/resume/step/stop, "
|
||||
"without world queries.\n"
|
||||
"--project loads Lua declared in project.faset.json; packaged projects "
|
||||
"are discovered beside the scene or executable.\n"
|
||||
"--watch-lua enables development-only script reload (or control "
|
||||
"reload-lua): the scene restarts, runtime state is not preserved.\n"
|
||||
"--profile requires explicit --frames 1..100000; measured durations "
|
||||
"include the first frame and renderer GPU waits/readback.\n"
|
||||
"Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, "
|
||||
@@ -218,6 +230,8 @@ int player_main(int argc, char** argv) {
|
||||
controlPath = faset::path_from_utf8(value());
|
||||
else if (arg == "--profile")
|
||||
profilePath = faset::path_from_utf8(value());
|
||||
else if (arg == "--project")
|
||||
projectRoot = faset::path_from_utf8(value());
|
||||
else if (arg == "--frames")
|
||||
maximumFrames = count(value());
|
||||
else if (arg == "--headless")
|
||||
@@ -226,6 +240,8 @@ int player_main(int argc, char** argv) {
|
||||
validateOnly = true;
|
||||
else if (arg == "--debug-physics")
|
||||
debugPhysics = true;
|
||||
else if (arg == "--watch-lua")
|
||||
watchLua = true;
|
||||
else
|
||||
throw std::invalid_argument("Unknown option: " + arg);
|
||||
}
|
||||
@@ -234,9 +250,22 @@ int player_main(int argc, char** argv) {
|
||||
validateOnly))
|
||||
throw std::invalid_argument("--profile requires an output path and explicit --frames "
|
||||
"1..100000, without --validate");
|
||||
if (options.contains("--project") && projectRoot.empty())
|
||||
throw std::invalid_argument("--project requires a nonempty root path");
|
||||
if (scenePath.empty())
|
||||
scenePath = executableDirectory(argv[0]) / "scene.fscene";
|
||||
scenePath = std::filesystem::absolute(scenePath).lexically_normal();
|
||||
const auto executableRoot = executableDirectory(argv[0]);
|
||||
if (projectRoot.empty()) {
|
||||
if (std::filesystem::is_regular_file(scenePath.parent_path() / "project.faset.json"))
|
||||
projectRoot = scenePath.parent_path();
|
||||
else if (std::filesystem::is_regular_file(executableRoot / "project.faset.json"))
|
||||
projectRoot = executableRoot;
|
||||
}
|
||||
if (!projectRoot.empty())
|
||||
projectRoot = std::filesystem::absolute(projectRoot).lexically_normal();
|
||||
if (watchLua && (projectRoot.empty() || validateOnly))
|
||||
throw std::invalid_argument("--watch-lua requires a project, without --validate");
|
||||
if (assetsPath.empty())
|
||||
assetsPath = scenePath.parent_path();
|
||||
if (!std::filesystem::is_directory(assetsPath))
|
||||
@@ -246,10 +275,31 @@ int player_main(int argc, char** argv) {
|
||||
maximumFrames = 1;
|
||||
const auto sceneReadStarted = Clock::now();
|
||||
const auto document = faset::player::readScene(scenePath);
|
||||
faset::runtime::validate_scene_schemas(document, faset::gameplay::schema());
|
||||
const auto nativeSchema = faset::gameplay::schema();
|
||||
if (!nativeSchema.is_array())
|
||||
throw std::runtime_error("Gameplay schema() must return a type array");
|
||||
const auto luaProject = projectRoot.empty() ? faset::scripting::LuaProject{}
|
||||
: faset::scripting::loadLuaProject(projectRoot);
|
||||
auto schema = nativeSchema;
|
||||
#if defined(FASET_HAS_LUA)
|
||||
std::unique_ptr<faset::scripting::LuaModule> lua;
|
||||
if (luaProject.enabled()) {
|
||||
lua = std::make_unique<faset::scripting::LuaModule>(luaProject);
|
||||
for (const auto& type : lua->schema())
|
||||
schema.push_back(type);
|
||||
}
|
||||
#else
|
||||
if (luaProject.enabled() || watchLua)
|
||||
throw std::runtime_error("This Player was built without Lua support; configure "
|
||||
"FASET_ENABLE_LUA=ON for this project");
|
||||
#endif
|
||||
faset::runtime::validate_scene_schemas(document, schema);
|
||||
#if defined(FASET_HAS_LUA)
|
||||
if (lua)
|
||||
lua->validateScene(document);
|
||||
#endif
|
||||
const auto config = simulationConfig(document);
|
||||
const auto sceneReadFinished = Clock::now();
|
||||
const auto executableRoot = executableDirectory(argv[0]);
|
||||
if (scenePath.extension() == ".fscene" &&
|
||||
std::filesystem::equivalent(scenePath.parent_path(), executableRoot))
|
||||
validatePackagedShaders(executableRoot);
|
||||
@@ -272,9 +322,24 @@ int player_main(int argc, char** argv) {
|
||||
return 0;
|
||||
}
|
||||
const auto worldStarted = Clock::now();
|
||||
faset::runtime::Runtime world(config);
|
||||
faset::gameplay::registerGameplay(world);
|
||||
world.load(document);
|
||||
auto world = std::make_unique<faset::runtime::Runtime>(config);
|
||||
faset::gameplay::registerGameplay(*world);
|
||||
#if defined(FASET_HAS_LUA)
|
||||
if (lua)
|
||||
lua->registerBehaviors(*world);
|
||||
#endif
|
||||
world->load(document);
|
||||
std::size_t logCursor = 0;
|
||||
auto printGameplayLogs = [&]() {
|
||||
while (logCursor < world->diagnostics().size())
|
||||
std::cerr << world->diagnostics()[logCursor++] << '\n';
|
||||
#if defined(FASET_HAS_LUA)
|
||||
if (lua)
|
||||
for (const auto& message : lua->takeLogs())
|
||||
std::cerr << message << '\n';
|
||||
#endif
|
||||
};
|
||||
printGameplayLogs();
|
||||
faset::player::SceneView view(assetsPath);
|
||||
const auto rendererStarted = Clock::now();
|
||||
faset::render::Renderer renderer(
|
||||
@@ -287,16 +352,78 @@ int player_main(int argc, char** argv) {
|
||||
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();
|
||||
#if defined(FASET_HAS_LUA)
|
||||
auto lastLuaCheck = Clock::now();
|
||||
std::string lastLuaFingerprint = luaProject.fingerprint;
|
||||
std::string lastLuaReloadError;
|
||||
auto reloadLua = [&](bool force) {
|
||||
if (!watchLua)
|
||||
return;
|
||||
const auto now = Clock::now();
|
||||
if (!force && now - lastLuaCheck < std::chrono::milliseconds(500))
|
||||
return;
|
||||
lastLuaCheck = now;
|
||||
std::string candidateFingerprint;
|
||||
try {
|
||||
const auto candidateProject = faset::scripting::loadLuaProject(projectRoot);
|
||||
candidateFingerprint = candidateProject.fingerprint;
|
||||
if (!force && candidateProject.fingerprint == lastLuaFingerprint)
|
||||
return;
|
||||
// Do not repeatedly execute a broken candidate every half-second.
|
||||
// A corrected source or manifest produces a new fingerprint.
|
||||
lastLuaFingerprint = candidateProject.fingerprint;
|
||||
auto candidateSchema = nativeSchema;
|
||||
std::unique_ptr<faset::scripting::LuaModule> candidateLua;
|
||||
if (candidateProject.enabled()) {
|
||||
candidateLua = std::make_unique<faset::scripting::LuaModule>(candidateProject);
|
||||
for (const auto& type : candidateLua->schema())
|
||||
candidateSchema.push_back(type);
|
||||
}
|
||||
faset::runtime::validate_scene_schemas(document, candidateSchema);
|
||||
if (candidateLua)
|
||||
candidateLua->validateScene(document);
|
||||
auto candidateWorld = std::make_unique<faset::runtime::Runtime>(config);
|
||||
faset::gameplay::registerGameplay(*candidateWorld);
|
||||
if (candidateLua)
|
||||
candidateLua->registerBehaviors(*candidateWorld);
|
||||
candidateWorld->load(document);
|
||||
// Runtime isolates callback exceptions into diagnostics. A bad
|
||||
// on_start must not replace the currently running scene.
|
||||
if (!candidateWorld->diagnostics().empty())
|
||||
throw std::runtime_error(candidateWorld->diagnostics().front());
|
||||
candidateWorld->setPaused(world->paused());
|
||||
world->clear();
|
||||
printGameplayLogs();
|
||||
world = std::move(candidateWorld);
|
||||
lua = std::move(candidateLua);
|
||||
logCursor = 0;
|
||||
printGameplayLogs();
|
||||
lastLuaReloadError.clear();
|
||||
// Compilation and initialization are not simulation wall time.
|
||||
previous = Clock::now();
|
||||
std::cerr << "Lua reloaded: scene restarted; runtime state reset\n";
|
||||
} catch (const std::exception& error) {
|
||||
const std::string message =
|
||||
std::string("Lua reload rejected; previous scene retained: ") + error.what();
|
||||
// Bad/missing manifests may fail before a fingerprint exists.
|
||||
// Retry them on the next poll but report an unchanged failure once.
|
||||
const auto failure = candidateFingerprint + "\n" + message;
|
||||
if (force || failure != lastLuaReloadError)
|
||||
std::cerr << message << '\n';
|
||||
lastLuaReloadError = failure;
|
||||
}
|
||||
};
|
||||
#endif
|
||||
while (!stop && !renderer.should_close() &&
|
||||
(maximumFrames == 0 || frames < maximumFrames)) {
|
||||
const auto frameStarted = Clock::now();
|
||||
faset::runtime::InputState input;
|
||||
bool singleStep = false;
|
||||
bool requestLuaReload = false;
|
||||
if (!controlPath.empty() && std::filesystem::is_regular_file(controlPath)) {
|
||||
try {
|
||||
if (std::filesystem::file_size(controlPath) > 65536)
|
||||
@@ -314,14 +441,16 @@ int player_main(int argc, char** argv) {
|
||||
if (value > controlSequence) {
|
||||
const auto command = message.at("command").get<std::string>();
|
||||
if (command == "pause")
|
||||
world.setPaused(true);
|
||||
world->setPaused(true);
|
||||
else if (command == "resume")
|
||||
world.setPaused(false);
|
||||
world->setPaused(false);
|
||||
else if (command == "step") {
|
||||
world.setPaused(true);
|
||||
world->setPaused(true);
|
||||
singleStep = true;
|
||||
} else if (command == "stop")
|
||||
stop = true;
|
||||
else if (command == "reload-lua" && watchLua)
|
||||
requestLuaReload = true;
|
||||
else
|
||||
throw std::invalid_argument("unsupported control command");
|
||||
controlSequence = value;
|
||||
@@ -355,7 +484,7 @@ int player_main(int argc, char** argv) {
|
||||
if (key == "E")
|
||||
input.interactPressed = true;
|
||||
if (key == "P")
|
||||
world.setPaused(!world.paused());
|
||||
world->setPaused(!world->paused());
|
||||
if (key == "N")
|
||||
singleStep = true;
|
||||
if (key == "F3")
|
||||
@@ -365,6 +494,11 @@ int player_main(int argc, char** argv) {
|
||||
}
|
||||
if (stop)
|
||||
break;
|
||||
#if defined(FASET_HAS_LUA)
|
||||
reloadLua(requestLuaReload);
|
||||
#else
|
||||
(void)requestLuaReload;
|
||||
#endif
|
||||
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")) -
|
||||
@@ -375,14 +509,15 @@ int player_main(int argc, char** argv) {
|
||||
: std::chrono::duration<double>(now - previous).count();
|
||||
previous = now;
|
||||
const auto simulationStarted = Clock::now();
|
||||
const auto runtimeStats = singleStep && world.paused() ? world.singleStep(input)
|
||||
: world.advance(elapsed, input);
|
||||
const auto runtimeStats = singleStep && world->paused()
|
||||
? world->singleStep(input)
|
||||
: world->advance(elapsed, input);
|
||||
const auto simulationFinished = Clock::now();
|
||||
const auto presentation = world.snapshotJson();
|
||||
const auto presentation = world->snapshotJson();
|
||||
auto snapshot = view.build(presentation, static_cast<float>(renderer.width()) /
|
||||
std::max(1u, renderer.height()));
|
||||
if (debugPhysics)
|
||||
view.appendPhysicsDebug(snapshot, physicsScene(world, presentation));
|
||||
view.appendPhysicsDebug(snapshot, physicsScene(*world, presentation));
|
||||
const auto snapshotFinished = Clock::now();
|
||||
for (const auto& diagnostic : view.diagnostics()) {
|
||||
if (diagnostic.starts_with("error:"))
|
||||
@@ -390,8 +525,7 @@ int player_main(int argc, char** argv) {
|
||||
if (reported.insert(diagnostic).second)
|
||||
std::cerr << diagnostic << '\n';
|
||||
}
|
||||
while (logCursor < world.diagnostics().size())
|
||||
std::cerr << world.diagnostics()[logCursor++] << '\n';
|
||||
printGameplayLogs();
|
||||
const auto renderStarted = Clock::now();
|
||||
renderer.render(snapshot);
|
||||
const auto frameFinished = Clock::now();
|
||||
@@ -409,12 +543,11 @@ int player_main(int argc, char** argv) {
|
||||
}
|
||||
++frames;
|
||||
}
|
||||
const auto completedTicks = world.snapshot().tick;
|
||||
const auto completedTicks = world->snapshot().tick;
|
||||
// Run normal shutdown while diagnostics are still observable. Runtime's
|
||||
// destructor is a fallback and cannot print messages after this scope ends.
|
||||
world.clear();
|
||||
while (logCursor < world.diagnostics().size())
|
||||
std::cerr << world.diagnostics()[logCursor++] << '\n';
|
||||
world->clear();
|
||||
printGameplayLogs();
|
||||
if (!capturePath.empty()) {
|
||||
if (frames == 0)
|
||||
throw std::runtime_error("No frame was rendered for capture");
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/runtime/schema.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#if defined(FASET_HAS_LUA)
|
||||
#include <faset/scripting/LuaModule.hpp>
|
||||
#endif
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
int schema_main(int argc, char** argv) {
|
||||
try {
|
||||
std::filesystem::path output;
|
||||
std::filesystem::path output, projectRoot;
|
||||
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";
|
||||
std::cout << "faset_schema_exporter [--output PATH] [--project ROOT]\n"
|
||||
"Exports C++ and declared Lua gameplay schemas without creating a "
|
||||
"world or invoking lifecycle callbacks.\n";
|
||||
return 0;
|
||||
}
|
||||
if (argument == "--output" && i + 1 < argc && output.empty())
|
||||
output = faset::path_from_utf8(argv[++i]);
|
||||
else if (argument == "--project" && i + 1 < argc && projectRoot.empty())
|
||||
projectRoot = faset::path_from_utf8(argv[++i]);
|
||||
else
|
||||
throw std::invalid_argument("Unknown, repeated or incomplete argument: " +
|
||||
argument);
|
||||
}
|
||||
const auto types = faset::gameplay::schema();
|
||||
auto types = faset::gameplay::schema();
|
||||
if (!types.is_array())
|
||||
throw std::runtime_error("Gameplay schema() must return a type array");
|
||||
if (!projectRoot.empty()) {
|
||||
const auto project = faset::scripting::loadLuaProject(projectRoot);
|
||||
if (project.enabled()) {
|
||||
#if defined(FASET_HAS_LUA)
|
||||
faset::scripting::LuaModule lua(project);
|
||||
for (const auto& type : lua.schema())
|
||||
types.push_back(type);
|
||||
#else
|
||||
throw std::runtime_error("This schema exporter was built without Lua support; "
|
||||
"configure FASET_ENABLE_LUA=ON for this project");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
// Check all IDs, including unused types, before publishing a manifest.
|
||||
// This player-side boundary intentionally has no authoring dependency.
|
||||
faset::runtime::validate_scene_schemas({{"entities", nlohmann::json::array()}}, types);
|
||||
const nlohmann::json manifest{{"format", "faset.schema"}, {"version", 1}, {"types", types}};
|
||||
if (output.empty())
|
||||
std::cout << manifest.dump(2) << '\n';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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_assets faset_authoring Threads::Threads)
|
||||
target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring 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)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Official Lua sources are checksum-pinned in dependencies.lock.json. Build only
|
||||
# the VM and libraries, never the standalone lua/luac executables or a system ABI.
|
||||
faset_dependency(lua)
|
||||
set(lua_src "${FASET_lua_SOURCE_DIR}/src")
|
||||
add_library(faset_lua_vendor STATIC
|
||||
${lua_src}/lapi.c ${lua_src}/lcode.c ${lua_src}/lctype.c
|
||||
${lua_src}/ldebug.c ${lua_src}/ldo.c ${lua_src}/ldump.c
|
||||
${lua_src}/lfunc.c ${lua_src}/lgc.c ${lua_src}/llex.c
|
||||
${lua_src}/lmem.c ${lua_src}/lobject.c ${lua_src}/lopcodes.c
|
||||
${lua_src}/lparser.c ${lua_src}/lstate.c ${lua_src}/lstring.c
|
||||
${lua_src}/ltable.c ${lua_src}/ltm.c ${lua_src}/lundump.c
|
||||
${lua_src}/lvm.c ${lua_src}/lzio.c ${lua_src}/lauxlib.c
|
||||
${lua_src}/lbaselib.c ${lua_src}/lmathlib.c ${lua_src}/lstrlib.c
|
||||
${lua_src}/ltablib.c ${lua_src}/lutf8lib.c)
|
||||
target_include_directories(faset_lua_vendor SYSTEM PUBLIC "${lua_src}")
|
||||
if(NOT MSVC)
|
||||
# Upstream intentionally uses compiler-supported computed gotos in the VM.
|
||||
target_compile_options(faset_lua_vendor PRIVATE -Wno-pedantic)
|
||||
endif()
|
||||
if(UNIX)
|
||||
target_link_libraries(faset_lua_vendor PUBLIC m)
|
||||
endif()
|
||||
add_library(faset_lua STATIC ${PROJECT_SOURCE_DIR}/src/scripting/LuaModule.cpp)
|
||||
add_library(Faset::Lua ALIAS faset_lua)
|
||||
target_include_directories(faset_lua PUBLIC ${PROJECT_SOURCE_DIR}/include)
|
||||
target_link_libraries(faset_lua PUBLIC faset_runtime faset_scripting_project PRIVATE faset_lua_vendor)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(faset_lua_tests ${PROJECT_SOURCE_DIR}/tests/lua_tests.cpp)
|
||||
target_link_libraries(faset_lua_tests PRIVATE faset_lua)
|
||||
target_compile_definitions(faset_lua_tests PRIVATE FASET_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME lua_contracts COMMAND faset_lua_tests)
|
||||
set_tests_properties(lua_contracts PROPERTIES TIMEOUT 30)
|
||||
add_executable(faset_lua_safety_tests ${PROJECT_SOURCE_DIR}/tests/lua_safety_tests.cpp)
|
||||
target_link_libraries(faset_lua_safety_tests PRIVATE faset_lua)
|
||||
add_test(NAME lua_safety_contracts COMMAND faset_lua_safety_tests)
|
||||
set_tests_properties(lua_safety_contracts PROPERTIES TIMEOUT 30)
|
||||
endif()
|
||||
+34
-3
@@ -1,6 +1,10 @@
|
||||
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)
|
||||
target_link_libraries(faset_schema_exporter PRIVATE faset_core faset_gameplay faset_scripting_project)
|
||||
if(TARGET faset_lua)
|
||||
target_link_libraries(faset_schema_exporter PRIVATE faset_lua)
|
||||
target_compile_definitions(faset_schema_exporter PRIVATE FASET_HAS_LUA=1)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(TARGET faset_runtime AND TARGET faset_render AND TARGET faset_assets)
|
||||
@@ -14,7 +18,11 @@ if(TARGET faset_runtime AND TARGET faset_render AND TARGET faset_assets)
|
||||
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)
|
||||
target_link_libraries(faset_player PRIVATE faset_scene_view faset_runtime faset_gameplay faset_scripting_project)
|
||||
if(TARGET faset_lua)
|
||||
target_link_libraries(faset_player PRIVATE faset_lua)
|
||||
target_compile_definitions(faset_player PRIVATE FASET_HAS_LUA=1)
|
||||
endif()
|
||||
install(TARGETS faset_player RUNTIME DESTINATION .)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(faset_player_tests ${PROJECT_SOURCE_DIR}/tests/runtime_player_tests.cpp)
|
||||
@@ -28,7 +36,11 @@ if(TARGET faset_runtime AND TARGET faset_render AND TARGET faset_assets)
|
||||
${PROJECT_SOURCE_DIR}/tests/player_diagnostics/Gameplay.cpp)
|
||||
target_include_directories(faset_player_diagnostics PRIVATE
|
||||
${PROJECT_SOURCE_DIR}/tests/player_diagnostics)
|
||||
target_link_libraries(faset_player_diagnostics PRIVATE faset_scene_view faset_runtime)
|
||||
target_link_libraries(faset_player_diagnostics PRIVATE faset_scene_view faset_runtime faset_scripting_project)
|
||||
if(TARGET faset_lua)
|
||||
target_link_libraries(faset_player_diagnostics PRIVATE faset_lua)
|
||||
target_compile_definitions(faset_player_diagnostics PRIVATE FASET_HAS_LUA=1)
|
||||
endif()
|
||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||
add_test(NAME player_shutdown_diagnostics COMMAND ${Python3_EXECUTABLE}
|
||||
${PROJECT_SOURCE_DIR}/tests/player_diagnostics_test.py
|
||||
@@ -36,3 +48,22 @@ if(TARGET faset_runtime AND TARGET faset_render AND TARGET faset_assets)
|
||||
set_tests_properties(player_shutdown_diagnostics PROPERTIES LABELS "gpu" TIMEOUT 60)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(BUILD_TESTING AND TARGET faset_schema_exporter)
|
||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||
set(FASET_LUA_CLI_TEST_ARGS --exporter $<TARGET_FILE:faset_schema_exporter>)
|
||||
if(TARGET faset_player)
|
||||
list(APPEND FASET_LUA_CLI_TEST_ARGS --player $<TARGET_FILE:faset_player>)
|
||||
endif()
|
||||
if(NOT TARGET faset_lua)
|
||||
list(APPEND FASET_LUA_CLI_TEST_ARGS --disabled)
|
||||
endif()
|
||||
add_test(NAME lua_cli_contracts COMMAND ${Python3_EXECUTABLE}
|
||||
${PROJECT_SOURCE_DIR}/tests/lua_cli_test.py ${FASET_LUA_CLI_TEST_ARGS})
|
||||
set_tests_properties(lua_cli_contracts PROPERTIES TIMEOUT 90)
|
||||
if(TARGET faset_lua AND TARGET faset_player)
|
||||
add_test(NAME lua_player_reload COMMAND ${Python3_EXECUTABLE}
|
||||
${PROJECT_SOURCE_DIR}/tests/lua_player_reload_test.py $<TARGET_FILE:faset_player>)
|
||||
set_tests_properties(lua_player_reload PROPERTIES LABELS "gpu" TIMEOUT 90)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
+5
-2
@@ -7,8 +7,11 @@ target_include_directories(faset_runtime PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../inc
|
||||
target_link_libraries(faset_runtime PUBLIC nlohmann_json::nlohmann_json PRIVATE EnTT::EnTT box2d box3d)
|
||||
|
||||
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")
|
||||
if(NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.cpp" AND NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.hpp" AND FASET_ENABLE_LUA)
|
||||
# A Lua-only game needs the same stable native entry points, but no user C++.
|
||||
set(FASET_GAMEPLAY_SOURCE_DIR "${PROJECT_SOURCE_DIR}/src/scripting/empty_gameplay")
|
||||
elseif(NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.cpp" OR NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.hpp")
|
||||
message(FATAL_ERROR "Provide both Gameplay.cpp and Gameplay.hpp, or enable Lua for a Lua-only project")
|
||||
endif()
|
||||
add_library(faset_gameplay STATIC "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.cpp")
|
||||
add_library(Faset::Gameplay ALIAS faset_gameplay)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"format": 1,
|
||||
"dependencies": {
|
||||
"lua": {
|
||||
"repository": "https://www.lua.org",
|
||||
"version": "5.4.9",
|
||||
"commit": "5.4.9",
|
||||
"url": "https://www.lua.org/ftp/lua-5.4.9.tar.gz",
|
||||
"sha256": "2335b6c582a52654f94612bf10d2f4672805d05329aa6568b1d8cd9e5c6fb8e6",
|
||||
"license": "MIT"
|
||||
},
|
||||
"sdl3": {
|
||||
"repository": "https://github.com/libsdl-org/SDL",
|
||||
"version": "release-3.2.20",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
Lua 5.4.9 — MIT License
|
||||
https://www.lua.org/license.html
|
||||
|
||||
Copyright (C) 1994-2026 Lua.org, PUC-Rio.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -40,13 +40,32 @@ build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension
|
||||
You can also run `build/linux-debug/faset_editor` without arguments to open the
|
||||
project launcher and create or select a project using the native interface.
|
||||
|
||||
Use **Build C++** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
|
||||
Use **Build** 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
|
||||
AddressSanitizer and UndefinedBehaviorSanitizer for tests without the graphics backend.
|
||||
|
||||
## Optional Lua module
|
||||
|
||||
Engine development builds enable `FASET_ENABLE_LUA` by default. Lua 5.4.9 is compiled
|
||||
from its checksum-pinned source archive; no system Lua installation is required.
|
||||
Pass `-DFASET_ENABLE_LUA=OFF` to omit the VM and bindings. The Editor's project
|
||||
build/export service selects this flag from `scripting.lua.scripts` in
|
||||
`project.faset.json`, so C++-only games do not link Lua.
|
||||
|
||||
See the [Lua guide](../scripting/lua.md) for the manifest, a Lua-only project,
|
||||
hot reload, and external-editor/LuaLS setup. Headless CPU checks can be run with:
|
||||
|
||||
```sh
|
||||
cmake --preset linux-debug -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF
|
||||
cmake --build --preset linux-debug --parallel
|
||||
ctest --preset linux-debug
|
||||
```
|
||||
|
||||
These checks do not verify the graphical Player or renderer.
|
||||
|
||||
## Dependencies and offline builds
|
||||
|
||||
Dependency source URLs, commits, and archive SHA-256 values are stored in
|
||||
|
||||
@@ -9,10 +9,11 @@ and how those functions interact with scenes, physics, and the editor.
|
||||
Blender import and standalone Linux/Windows export. Acceptance used a physical
|
||||
Linux GPU and software Vulkan on Windows. See the
|
||||
[acceptance dossier](https://github.com/emil28092005/Faset_Engine/blob/main/docs/validation/mvp-acceptance.md)
|
||||
for exact source revisions and coverage limits. Lua and advanced graphics remain
|
||||
later milestones.
|
||||
for exact source revisions and coverage limits. The optional Lua module is
|
||||
documented separately; those historical acceptance results do not certify later
|
||||
changes. Advanced graphics remain later milestones.
|
||||
|
||||
Start with [how C++ gameplay works](scripting/index.md), then read
|
||||
Start with [how gameplay works](scripting/index.md), then read
|
||||
[frame and physics updates](scripting/lifecycle.md). See
|
||||
[Build from source](getting-started/build.md) for the toolchain and build commands.
|
||||
|
||||
@@ -30,7 +31,9 @@ The manual grows alongside tested engine capabilities, in this order:
|
||||
5. Work with scene templates, assets, and references.
|
||||
6. Import from Blender and export a standalone game.
|
||||
|
||||
Lua is planned after the C++ foundation. It is not a current scripting option.
|
||||
For interpreted gameplay, follow [Lua gameplay](scripting/lua.md): declare component
|
||||
fields, write callbacks, and reload scripts during development Play. The Lua-only
|
||||
example in `examples/lua` uses the same physics and scene model as the C++ tutorials.
|
||||
|
||||
## Preview this manual
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# C++ gameplay
|
||||
# Gameplay scripting
|
||||
|
||||
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.
|
||||
Faset supports **C++ compiled into the Player** and optional [Lua gameplay](lua.md).
|
||||
Both languages register component schemas and callbacks on the same runtime.
|
||||
For C++, there is no interpreter or live replacement of compiled classes: stop Play,
|
||||
rebuild, export the schema, and start a new Player session.
|
||||
|
||||
Lua is planned for a later stage. The APIs and tutorials in this section describe the C++ implementation available now.
|
||||
The following tutorials describe C++. See [Lua gameplay](lua.md) for Lua-only or
|
||||
mixed projects, the Lua API, source reload, and external-editor completion.
|
||||
|
||||
## Start here
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# Lua gameplay
|
||||
|
||||
Faset embeds **Lua 5.4.9** as an optional gameplay module. Lua and compiled C++
|
||||
behaviors share the same runtime lifecycle, typed entity operations, scene components,
|
||||
and Inspector metadata. The Editor does not run gameplay code in its own process.
|
||||
There is no built-in script editor: edit `.lua` files in Zed or another external editor.
|
||||
|
||||
## Enable Lua in a project
|
||||
|
||||
Add explicit entry scripts to `project.faset.json`:
|
||||
|
||||
```json
|
||||
"scripting": {
|
||||
"lua": {
|
||||
"scripts": ["Scripts/player.lua", "Scripts/beacon.lua"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a manifest fragment, not a complete project file. Each entry must return one
|
||||
`faset.behavior` table with a unique custom TypeId. All sources live beneath `Scripts`
|
||||
and are captured as an immutable build/export snapshot. Paths must be project-relative;
|
||||
symlinks and paths outside `Scripts` are rejected. Auxiliary modules do not need to
|
||||
appear in the entry list.
|
||||
|
||||
A Lua-only project can omit both `Scripts/Gameplay.cpp` and `Scripts/Gameplay.hpp`.
|
||||
A mixed project keeps that pair and adds the Lua declaration. TypeIds must be unique
|
||||
across both languages, and the `faset.*` namespace is reserved for native components.
|
||||
|
||||
The engine developer option `FASET_ENABLE_LUA` defaults to `ON`. Project builds select
|
||||
it from the manifest, so a C++-only game does not link the Lua VM. Lua is pinned and
|
||||
built from source; no system Lua installation is required.
|
||||
|
||||
The complete `examples/lua` project includes a playable
|
||||
2D controller, a non-physical animated beacon, and a shared module. Its scripts are
|
||||
also loaded by the Lua contract test.
|
||||
|
||||
## Write a behavior
|
||||
|
||||
```lua
|
||||
local Player = faset.behavior {
|
||||
id = "game.player",
|
||||
version = 1,
|
||||
name = "Player",
|
||||
fields = {
|
||||
speed = {
|
||||
name = "Move speed", type = "number", default = 5,
|
||||
min = 0, max = 30, units = "m/s"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Player:on_start()
|
||||
self.state.elapsed = 0
|
||||
end
|
||||
|
||||
function Player:fixed_update(delta)
|
||||
self.state.elapsed = self.state.elapsed + delta
|
||||
local velocity = self.entity:velocity()
|
||||
velocity.x = faset.input().horizontal * self.fields.speed
|
||||
self.entity:set_velocity(velocity)
|
||||
end
|
||||
|
||||
return Player
|
||||
```
|
||||
|
||||
Attach a component with `type: "game.player"`, `version: 1`, and the desired field
|
||||
overrides to an entity with a 2D or 3D rigid body. Refresh schemas to expose the
|
||||
behavior in **Add Component** and its `speed` field in the Inspector. Saved scenes
|
||||
store the stable TypeId and data, not an instance of a Lua object.
|
||||
|
||||
Each entity/component gets its own instance:
|
||||
|
||||
- `self.entity`: an opaque runtime handle, checked on every call.
|
||||
- `self.fields`: a configuration copy, combining schema defaults and scene overrides.
|
||||
- `self.state`: a fresh mutable table for counters, timers, and retained handles.
|
||||
|
||||
Changing either table does not modify the saved scene or create an Undo operation.
|
||||
Module-local variables are shared by instances of that module; put per-entity state
|
||||
in `self.state`. Lua tables returned by getters are copies, not native pointers.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Use colon definitions so Lua supplies `self`:
|
||||
|
||||
| Callback | When it runs |
|
||||
|---|---|
|
||||
| `on_start()` | Once after the instance and initial scene objects exist |
|
||||
| `fixed_update(delta)` | Before each fixed physics step; delta is seconds |
|
||||
| `on_collision(event)` | After physics, for contact begin/end |
|
||||
| `update(delta)` | Once per rendered frame after fixed steps |
|
||||
| `late_update(delta)` | After presentation interpolation |
|
||||
| `on_destroy()` | Before component/entity removal, while the handle is still valid |
|
||||
|
||||
Omit unused callbacks. The same [timing rules](lifecycle.md) as C++ apply, including
|
||||
input edges, fixed-tick catch-up, deferred structural changes, pause and single-step.
|
||||
Do not multiply velocity by delta; multiply a manually calculated displacement.
|
||||
|
||||
An error is reported with source location/traceback and disables the offending
|
||||
instance for that generation and releases its instance state. Other instances can continue.
|
||||
The VM quota is shared: allocations retained by module-level variables can still
|
||||
affect other behaviors. A restart/reload creates
|
||||
fresh instances; disabled instances are not automatically retried every frame.
|
||||
Changes already made or queued by a failing callback are not rolled back.
|
||||
|
||||
## Runtime API
|
||||
|
||||
`faset.find("scene-id")` returns an entity handle or `nil`. Handles support equality
|
||||
and `:valid()`. Retained handles become invalid after destruction or scene restart;
|
||||
calling other methods on a stale handle reports an error.
|
||||
|
||||
| Entity method | Contract |
|
||||
|---|---|
|
||||
| `:transform()` / `:presentation()` | Copy of simulation/display transform |
|
||||
| `:set_transform(pose)` | Non-physical objects only |
|
||||
| `:set_presentation(pose)` | Display-only write during `late_update` |
|
||||
| `:teleport(pose)` | Explicit discontinuous pose change; preserves velocity |
|
||||
| `:fields(type_id)` | Copy of the named component's stored fields |
|
||||
| `:velocity()` / `:set_velocity(v)` | Linear velocity, rigid bodies only |
|
||||
| `:apply_impulse(v)` | Impulse at the rigid body's centre |
|
||||
| `:is_grounded()` | Support from completed native physics contacts |
|
||||
| `:destroy()` | Queue entity/descendant removal |
|
||||
| `:add_component(record)` | Queue a complete component record |
|
||||
| `:remove_component(type_id)` | Queue component removal |
|
||||
|
||||
Typed vectors are `{x = 1, y = 2, z = 0}`. Transforms contain `position`, `rotation`,
|
||||
and `scale`, each a named vector. Positions use metres; rotations use XYZ Euler
|
||||
radians. In contrast, **scene/component JSON arrays** are represented as ordinary
|
||||
1-based Lua arrays, such as `fields.position = {1, 2, 0}`. Use `faset.null` to retain
|
||||
an explicit JSON null; Lua `nil` removes a table key.
|
||||
An empty Lua table converts to a JSON object; an empty schema default with
|
||||
`type = "array"` is normalized to an empty JSON array.
|
||||
|
||||
`faset.input()` returns `horizontal`, `vertical`, `jump_pressed`, and
|
||||
`interact_pressed`. Player mappings are A/D or arrows, W/S or arrows, Space, and E.
|
||||
`faset.log(...)` sends a bounded message to Player logs and the Editor Console.
|
||||
|
||||
Collision events contain `first`, `second`, `other` (the opposite entity), and `began`.
|
||||
They are copied for Lua, but retained entity handles still need validity checks.
|
||||
|
||||
`faset.spawn(record)` queues a full scene entity record. It returns **no handle**:
|
||||
use `faset.find(id)` after the next fixed-tick barrier. Spawn, destroy, add and remove
|
||||
operations follow FIFO order and do not mutate Editor documents. For example:
|
||||
|
||||
```lua
|
||||
faset.spawn {
|
||||
id = "effect-1", name = "Effect", parent = faset.null,
|
||||
components = {
|
||||
{
|
||||
id = "effect-transform", type = "faset.transform", version = 1,
|
||||
fields = { position = {0, 2, 0} }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shared modules and sandbox
|
||||
|
||||
`require("util.motion")` resolves `Scripts/util/motion.lua`, then
|
||||
`Scripts/util/motion/init.lua`, inside the captured source snapshot. A module is
|
||||
evaluated once and its result cached within the VM. Missing modules, cycles, and
|
||||
path-like names are errors. There is no native module search, package installation,
|
||||
network access, or arbitrary file access.
|
||||
|
||||
Basic Lua operations and the `math`, `string`, `table`, and `utf8` libraries are
|
||||
available. `io`, `os`, `debug`, dynamic `load`, `loadfile`, `dofile`, `pcall`, `xpcall`,
|
||||
`setmetatable`, `collectgarbage`, `string.dump`, and coroutines are not exposed.
|
||||
Engine-owned metatables are locked; arbitrary finalizers cannot run during shutdown.
|
||||
The restricted API intentionally prevents scripts
|
||||
from catching execution-limit errors and continuing indefinitely.
|
||||
|
||||
The VM has memory and instruction budgets (`LuaLimits`, default 16 MiB and one
|
||||
million instructions per protected entry/callback). These are gameplay reliability
|
||||
limits, not a promise that executing untrusted code is equivalent to OS isolation.
|
||||
JSON conversion also limits nesting, node count and expanded string/key bytes
|
||||
(16 MiB), including repeated references to the same Lua string. Structural commands
|
||||
are limited to 1,024 operations and 16 MiB of marshaled payload per callback.
|
||||
The Player already runs separately from the Editor; only trusted local game projects
|
||||
should be opened and built. C++ gameplay is native code and is not sandboxed.
|
||||
|
||||
Schema extraction evaluates entry scripts in the bounded VM but does not create a
|
||||
world or invoke lifecycle callbacks. Keep top-level code declarative: calling runtime
|
||||
operations there is an error. Metadata supports the same fields, constraints and
|
||||
declarative [migration rules](api.md#editor-data-migrations) as C++ schemas. Runtime
|
||||
loading does not migrate saved data automatically.
|
||||
|
||||
## Edit, reload, and export
|
||||
|
||||
The Editor command palette exposes:
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `faset_lua_refresh` | Build if needed, extract schemas, refresh Inspector metadata |
|
||||
| `faset_lua_reload` | Request a Lua reload in a development Player |
|
||||
| `faset_lua_setup` | Install Faset LuaLS declarations/configuration |
|
||||
| `faset_script_open` | Open a script in an external editor |
|
||||
|
||||
The external-editor default is `zed`. Set `editor.script_editor` in
|
||||
`project.faset.json` to an argument array such as `["code", "--goto", "{file}"]`,
|
||||
or pass an `editor` argument array to `faset_script_open`. Exact `{file}` and
|
||||
`{project}` arguments are substituted; a missing file argument is appended. The
|
||||
command launches the executable directly, without a shell. The Assets panel lists
|
||||
Lua sources under `Scripts` and provides **Open Script**.
|
||||
|
||||
Development Play watches Lua changes. The Player's `--watch-lua` option enables this
|
||||
for direct development runs. A candidate source generation is loaded and validated
|
||||
before replacement; an invalid candidate leaves the preceding generation running.
|
||||
Successful reload **restarts the scene**, invalidates old handles, and resets all
|
||||
script state. This is not state-preserving hot swapping. C++ source changes still
|
||||
require a rebuild and a new Player process.
|
||||
|
||||
Export captures the declared entry list and Lua modules with the game. The exported
|
||||
Player runs without the Editor or a separate Lua installation; development watching
|
||||
is not enabled by ordinary exported-game launch. Exported Lua remains readable source,
|
||||
not encrypted code. A C++-only project continues to export without the Lua VM.
|
||||
|
||||
## Zed and LuaLS
|
||||
|
||||
Run `faset_lua_setup`. It copies annotation-only declarations to
|
||||
`.faset/lua/faset.lua` and creates `.luarc.json` **only if it does not already exist**.
|
||||
For an existing LuaLS configuration, merge these settings yourself:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime.version": "Lua 5.4",
|
||||
"runtime.path": ["Scripts/?.lua", "Scripts/?/init.lua"],
|
||||
"workspace.library": [".faset/lua"],
|
||||
"workspace.checkThirdParty": false,
|
||||
"diagnostics.globals": ["faset"]
|
||||
}
|
||||
```
|
||||
|
||||
Use an editor with LuaLS integration and open the project directory. Annotations
|
||||
describe the Faset API for completion and diagnostics; they are not runtime code and
|
||||
must not be `require`d. The engine does not embed an LSP client, code editor, or a
|
||||
breakpoint debugger. Player logs/tracebacks are the first debugging surface.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Lua module validation
|
||||
|
||||
Local implementation checks, 2026-09-18. These results supplement, not replace,
|
||||
the earlier MVP acceptance record. Toolchain: Linux x86-64, GCC 13.3, CMake 4.4.3,
|
||||
Ninja 1.13.2; pinned Lua 5.4.9.
|
||||
|
||||
## Observed results
|
||||
|
||||
| Configuration | Result |
|
||||
|---|---|
|
||||
| Lua enabled, renderer/editor UI disabled | 20/20 CTest tests passed |
|
||||
| Lua disabled, renderer/editor UI disabled | 18/18 CTest tests passed |
|
||||
| AddressSanitizer + UndefinedBehaviorSanitizer, Lua suites | 3/3 tests passed |
|
||||
| Renderer-linked native Player and SchemaExporter | Built successfully; CPU Lua CLI contracts passed |
|
||||
| Lua-only project without project C++ files | Empty native adapter built; sample validated; exactly two Lua schemas exported |
|
||||
| Native Editor and Editor UI library | Compiled and linked; Editor `--help` ran |
|
||||
| Manual | MkDocs strict build passed |
|
||||
|
||||
The Lua tests exercise lifecycle ordering, per-instance fields/state, VM ownership,
|
||||
stale/cross-world handles, deferred structural operations, native physics contacts,
|
||||
`require`, invalid schemas, CPU/memory limits and the shipped example scene. Additional
|
||||
safety cases cover deep/cyclic JSON, repeated-string/key expansion, structural queue
|
||||
limits, protected metatables, repeated OOM and reclamation of a failing instance.
|
||||
|
||||
BuildService tests exercise source snapshots, fingerprints, changes during a build,
|
||||
Lua-only projects, export contents/notices, and switching back to Lua-free games.
|
||||
Their native build/export fixture is a stand-in, not a graphical Player execution.
|
||||
|
||||
## Reproduce the CPU suite
|
||||
|
||||
```sh
|
||||
cmake -S . -B build/lua-check -G Ninja -DCMAKE_BUILD_TYPE=Debug \
|
||||
-DFASET_ENABLE_LUA=ON -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF
|
||||
cmake --build build/lua-check --parallel
|
||||
ctest --test-dir build/lua-check --output-on-failure
|
||||
```
|
||||
|
||||
Use a separate build directory with `-DFASET_ENABLE_LUA=OFF` for the optional-module
|
||||
check. For sanitizers, configure with `-DFASET_SANITIZERS=ON`, build the
|
||||
`faset_lua_tests`, `faset_lua_safety_tests`, and `faset_schema_exporter` targets, then
|
||||
run `ctest --test-dir <build> --output-on-failure -R '^lua_'`.
|
||||
|
||||
## Not verified here
|
||||
|
||||
- Windows compilation or execution of the new module.
|
||||
- Graphical/window interaction and real-time Lua reload in a running rendered game.
|
||||
`lua_player_reload` is provided as a GPU-labelled integration test for an equipped host.
|
||||
- A complete real Release export launched on a separate machine.
|
||||
- LeakSanitizer: this execution environment uses tracing incompatible with its
|
||||
process inspection, so sanitizer runs used `ASAN_OPTIONS=detect_leaks=0` and
|
||||
`UBSAN_OPTIONS=halt_on_error=1`. Address/undefined-behavior checks stayed enabled.
|
||||
|
||||
The renderer-linked CPU checks used the existing Vulkan loader, repo-pinned Vulkan
|
||||
headers and cached Slang, with SDL X11/Wayland disabled. No system graphics packages
|
||||
were installed. This proves linkage and CPU validation, not graphics compatibility.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Lua playground
|
||||
|
||||
A Lua-only project: there is no project `Gameplay.cpp`. The native engine and Player
|
||||
are built normally; the two gameplay behaviors are loaded from Lua source.
|
||||
|
||||
Open this folder as a project in the Editor, refresh Lua schemas, open
|
||||
`Scenes/main.scene.json`, then Play. **A/D** or arrows move, **Space** jumps from
|
||||
ground, and **E** resets the player. The gold beacon shows non-physical animation
|
||||
and a shared `require("util.motion")` module.
|
||||
|
||||
Change `Scripts/player.lua` in an external editor and save. Development Play watches
|
||||
Lua sources; a successful reload restarts the scene and resets script state. Invalid
|
||||
source leaves the previous generation running and reports the error. Export copies
|
||||
the captured Lua files into the game; a Lua executable or separately installed Lua
|
||||
library is not needed.
|
||||
|
||||
Run `faset_lua_setup` through the Editor command palette to install the Faset LuaLS
|
||||
declarations and, if absent, `.luarc.json`. See the
|
||||
[Lua manual](../../docs/manual/scripting/lua.md) for the API and sandbox boundaries.
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"format": "faset.scene",
|
||||
"version": 1,
|
||||
"id": "example-lua-scene",
|
||||
"name": "Lua playground — A/D / Space / E",
|
||||
"dimension": 2,
|
||||
"simulation": {
|
||||
"fixed_delta": 0.016666666666666666,
|
||||
"max_catch_up_ticks": 4,
|
||||
"physics_substeps": 4,
|
||||
"gravity": [0, -9.81, 0]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"id": "floor", "name": "Ground", "parent": null,
|
||||
"components": [
|
||||
{
|
||||
"id": "floor-transform", "type": "faset.transform", "version": 1,
|
||||
"fields": {"position": [0, -0.5, 0]}
|
||||
},
|
||||
{
|
||||
"id": "floor-sprite", "type": "faset.sprite", "version": 1,
|
||||
"fields": {"size": [16, 1], "color": [0.2, 0.3, 0.4, 1]}
|
||||
},
|
||||
{
|
||||
"id": "floor-body", "type": "faset.rigid_body_2d", "version": 1,
|
||||
"fields": {"body_type": "static", "half_extents": [8, 0.5]}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "player", "name": "Player", "parent": null,
|
||||
"components": [
|
||||
{
|
||||
"id": "player-transform", "type": "faset.transform", "version": 1,
|
||||
"fields": {"position": [-2, 1.5, 0]}
|
||||
},
|
||||
{
|
||||
"id": "player-sprite", "type": "faset.sprite", "version": 1,
|
||||
"fields": {"size": [0.8, 1], "color": [0.2, 0.75, 0.9, 1]}
|
||||
},
|
||||
{
|
||||
"id": "player-body", "type": "faset.rigid_body_2d", "version": 1,
|
||||
"fields": {"body_type": "dynamic", "half_extents": [0.4, 0.5], "friction": 0.3}
|
||||
},
|
||||
{
|
||||
"id": "player-controller", "type": "example.lua_player", "version": 1,
|
||||
"fields": {"move_speed": 5, "jump_speed": 7}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "beacon", "name": "Beacon", "parent": null,
|
||||
"components": [
|
||||
{
|
||||
"id": "beacon-transform", "type": "faset.transform", "version": 1,
|
||||
"fields": {"position": [3, 2, 0]}
|
||||
},
|
||||
{
|
||||
"id": "beacon-sprite", "type": "faset.sprite", "version": 1,
|
||||
"fields": {"size": [0.5, 0.5], "color": [1, 0.7, 0.2, 1]}
|
||||
},
|
||||
{
|
||||
"id": "beacon-behavior", "type": "example.lua_beacon", "version": 1,
|
||||
"fields": {"amplitude": 0.3, "frequency": 0.7}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"instances": []
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
local motion = require("util.motion")
|
||||
|
||||
local Beacon = faset.behavior {
|
||||
id = "example.lua_beacon",
|
||||
version = 1,
|
||||
name = "Lua Beacon",
|
||||
fields = {
|
||||
amplitude = { name = "Height", type = "number", default = 0.3, min = 0, max = 2 },
|
||||
frequency = { name = "Frequency", type = "number", default = 0.7, min = 0, max = 5 }
|
||||
}
|
||||
}
|
||||
|
||||
function Beacon:on_start()
|
||||
self.state.origin_y = self.entity:transform().position.y
|
||||
self.state.elapsed = 0
|
||||
end
|
||||
|
||||
function Beacon:update(delta)
|
||||
self.state.elapsed = self.state.elapsed + delta
|
||||
local pose = self.entity:transform()
|
||||
pose.position.y = self.state.origin_y
|
||||
+ motion.bob(self.state.elapsed, self.fields.frequency, self.fields.amplitude)
|
||||
pose.rotation.z = self.state.elapsed
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
|
||||
return Beacon
|
||||
@@ -0,0 +1,47 @@
|
||||
local Player = faset.behavior {
|
||||
id = "example.lua_player",
|
||||
version = 1,
|
||||
name = "Lua Player",
|
||||
fields = {
|
||||
move_speed = {
|
||||
name = "Move speed", type = "number", default = 5,
|
||||
min = 0, max = 30, units = "m/s"
|
||||
},
|
||||
jump_speed = {
|
||||
name = "Jump speed", type = "number", default = 7,
|
||||
min = 0, max = 20, units = "m/s"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Player:on_start()
|
||||
self.state.origin = self.entity:transform()
|
||||
self.state.jumps = 0
|
||||
faset.log("Lua player ready: A/D move, Space jump, E reset")
|
||||
end
|
||||
|
||||
function Player:fixed_update(delta)
|
||||
local input = faset.input()
|
||||
if input.interact_pressed then
|
||||
self.entity:teleport(self.state.origin)
|
||||
self.entity:set_velocity { x = 0, y = 0, z = 0 }
|
||||
self.state.jumps = 0
|
||||
return
|
||||
end
|
||||
|
||||
local velocity = self.entity:velocity()
|
||||
velocity.x = input.horizontal * self.fields.move_speed
|
||||
if input.jump_pressed and self.entity:is_grounded() then
|
||||
velocity.y = self.fields.jump_speed
|
||||
self.state.jumps = self.state.jumps + 1
|
||||
faset.log("Jump", self.state.jumps)
|
||||
end
|
||||
self.entity:set_velocity(velocity)
|
||||
|
||||
if self.entity:transform().position.y < -10 then
|
||||
self.entity:teleport(self.state.origin)
|
||||
self.entity:set_velocity { x = 0, y = 0, z = 0 }
|
||||
end
|
||||
end
|
||||
|
||||
return Player
|
||||
@@ -0,0 +1,7 @@
|
||||
local motion = {}
|
||||
|
||||
function motion.bob(time, frequency, amplitude)
|
||||
return math.sin(time * frequency * 2 * math.pi) * amplitude
|
||||
end
|
||||
|
||||
return motion
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"format": "faset.project",
|
||||
"version": 1,
|
||||
"id": "example-lua-project",
|
||||
"name": "Lua playground",
|
||||
"dimension": 2,
|
||||
"start_scene": "Scenes/main.scene.json",
|
||||
"scripting": {
|
||||
"lua": {
|
||||
"scripts": ["Scripts/player.lua", "Scripts/beacon.lua"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,9 @@ class Process {
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
std::filesystem::path find_executable(const std::string& name);
|
||||
// Launch an explicitly selected external application without an owning Process/job.
|
||||
// UTF-8 arguments remain literal (no shell); inherited environment, discarded stdio.
|
||||
// This does not grant permission to execute arbitrary project files as programs.
|
||||
void launch_detached(const std::vector<std::string>& arguments,
|
||||
const std::filesystem::path& working_directory = {});
|
||||
} // namespace faset
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace faset::scripting {
|
||||
|
||||
struct LuaLimits {
|
||||
std::size_t memoryBytes{16 * 1024 * 1024};
|
||||
unsigned instructions{1'000'000};
|
||||
};
|
||||
|
||||
// One sandboxed VM per module, isolated instance tables per entity/component.
|
||||
// Behavior callbacks retain shared ownership of the VM until Runtime is destroyed.
|
||||
class LuaModule {
|
||||
public:
|
||||
explicit LuaModule(const LuaProject& project, LuaLimits limits = {});
|
||||
~LuaModule();
|
||||
LuaModule(const LuaModule&) = delete;
|
||||
LuaModule& operator=(const LuaModule&) = delete;
|
||||
nlohmann::json schema() const;
|
||||
// Validates Lua component configuration without creating instances or running callbacks.
|
||||
// Pair with runtime::validate_scene_schemas for cross-language IDs and versions.
|
||||
void validateScene(const nlohmann::json& scene) const;
|
||||
void registerBehaviors(runtime::Runtime& runtime);
|
||||
std::vector<std::string> takeLogs();
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::shared_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace faset::scripting
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace faset::scripting {
|
||||
|
||||
// Immutable source snapshot. Keys and entries are project-relative UTF-8 paths.
|
||||
// Only Lua files below Scripts are accepted; loading never follows symlinks.
|
||||
struct LuaProject {
|
||||
std::vector<std::string> scripts;
|
||||
std::map<std::string, std::string> sources;
|
||||
std::string fingerprint;
|
||||
bool enabled() const noexcept {
|
||||
return !scripts.empty();
|
||||
}
|
||||
};
|
||||
|
||||
// project.faset.json: scripting.lua.scripts = ["Scripts/player.lua", ...].
|
||||
// An absent Lua declaration is a C++-only project.
|
||||
LuaProject loadLuaProject(const std::filesystem::path& projectRoot);
|
||||
|
||||
// Writes the captured sources, not live files. The caller owns the target manifest.
|
||||
void writeLuaSources(const LuaProject& project, const std::filesystem::path& targetRoot);
|
||||
|
||||
} // namespace faset::scripting
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
site_name: Faset Engine Manual
|
||||
site_description: Learn C++ gameplay, build scenes, and export games with Faset Engine.
|
||||
site_description: Learn C++ and Lua gameplay, build scenes, and export games with Faset Engine.
|
||||
repo_url: https://github.com/emil28092005/Faset_Engine
|
||||
repo_name: Faset_Engine
|
||||
docs_dir: docs/manual
|
||||
@@ -32,8 +32,9 @@ markdown_extensions:
|
||||
nav:
|
||||
- Start here: index.md
|
||||
- Build from source: getting-started/build.md
|
||||
- C++ gameplay:
|
||||
- Gameplay scripting:
|
||||
- How gameplay works: scripting/index.md
|
||||
- Lua gameplay: scripting/lua.md
|
||||
- Write your first behavior: scripting/first-behavior.md
|
||||
- Frame and physics updates: scripting/lifecycle.md
|
||||
- Physics and grounded movement: scripting/physics.md
|
||||
|
||||
@@ -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);
|
||||
|
||||
+106
-4
@@ -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 {
|
||||
try {
|
||||
const auto signature = source_signature();
|
||||
return {{"loaded", schema_loaded_},
|
||||
{"stale", !schema_loaded_ || schema_source_signature_ != source_signature() ||
|
||||
{"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
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/editor/commands.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
|
||||
@@ -154,6 +155,12 @@ int test_main(int argc, char** argv) {
|
||||
const auto previous_player = sha256_file(player);
|
||||
const auto previous_schema = read_text(schema);
|
||||
const auto previous_manifest = read_text(directory / "manifest.json");
|
||||
check(!first.result.at("lua_enabled").get<bool>() &&
|
||||
!fs::exists(directory / "project.faset.json"),
|
||||
"C++-only build publishes no Lua sources or project manifest");
|
||||
check(read_json(config.project_root / "configure-fixture.json").back() ==
|
||||
"-DFASET_ENABLE_LUA=OFF",
|
||||
"C++-only build explicitly disables the Lua VM in CMake");
|
||||
authoring.replace_external_schemas(read_json(schema));
|
||||
const auto previous_registry = authoring.schemas().manifest();
|
||||
check(authoring.schemas().schema("game.mover").at("version") == 2,
|
||||
@@ -251,8 +258,96 @@ int test_main(int argc, char** argv) {
|
||||
check(rejected && authoring.schemas().manifest() == previous_registry,
|
||||
"Cached-schema validation uses the same contract and preserves the registry");
|
||||
}
|
||||
atomic_write_json(config.project_root / "schema-fixture.json", valid);
|
||||
auto project = read_json(config.project_root / "project.faset.json");
|
||||
project["scripting"]["lua"]["scripts"] = Json::array({"Scripts/main.lua"});
|
||||
atomic_write_json(config.project_root / "project.faset.json", project);
|
||||
const auto lua_source =
|
||||
"return faset.behavior { id = 'game.mover', version = 2, fields = {} }\n";
|
||||
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
|
||||
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 1}\n");
|
||||
fs::remove(config.project_root / "Scripts/Gameplay.cpp");
|
||||
fs::remove(config.project_root / "Scripts/Gameplay.hpp");
|
||||
const auto lua_build = builds.wait(builds.start_build());
|
||||
check(lua_build.state == "succeeded" && lua_build.result.at("lua_enabled") == true,
|
||||
"Lua-only project builds without a Gameplay.cpp/Gameplay.hpp pair: " +
|
||||
lua_build.error);
|
||||
check(read_json(config.project_root / "configure-fixture.json").back() ==
|
||||
"-DFASET_ENABLE_LUA=ON",
|
||||
"Lua declaration enables the module in the native Player");
|
||||
const auto lua_directory =
|
||||
path_from_utf8(lua_build.result.at("directory").get<std::string>());
|
||||
const auto captured = scripting::loadLuaProject(lua_directory);
|
||||
check(captured.enabled() && captured.sources.size() == 2 &&
|
||||
captured.fingerprint ==
|
||||
lua_build.result.at("lua_fingerprint").get<std::string>() &&
|
||||
read_json(path_from_utf8(lua_build.result.at("schema").get<std::string>()))
|
||||
.at("lua_fingerprint")
|
||||
.get<std::string>() == captured.fingerprint,
|
||||
"Immutable source snapshot and merged schema share a Lua fingerprint");
|
||||
check(read_json(config.project_root / "exporter-project-fixture.json").at("scripting") ==
|
||||
project.at("scripting"),
|
||||
"Schema exporter receives the captured project's Lua entries");
|
||||
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 2}\n");
|
||||
const auto changed = builds.wait(builds.start_build());
|
||||
check(
|
||||
changed.state == "succeeded" &&
|
||||
changed.result.at("fingerprint") != lua_build.result.at("fingerprint") &&
|
||||
changed.result.at("lua_fingerprint") != lua_build.result.at("lua_fingerprint"),
|
||||
"Module-only Lua edits produce new build provenance without changing native fixtures");
|
||||
check(scripting::loadLuaProject(lua_directory).fingerprint == captured.fingerprint,
|
||||
"Later edits never mutate an already published Lua generation");
|
||||
const auto lua_pointer = read_text(last_build);
|
||||
for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) {
|
||||
atomic_write(config.project_root / marker, "fixture\n");
|
||||
const auto raced = builds.wait(builds.start_build());
|
||||
check(raced.state == "failed" && read_text(last_build) == lua_pointer,
|
||||
"Source or snapshot changes during schema export preserve the last good build");
|
||||
fs::remove(config.project_root / marker);
|
||||
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
|
||||
}
|
||||
const auto exported = builds.wait(builds.start_export(scene, root / "lua-export"));
|
||||
check(exported.state == "succeeded",
|
||||
"Lua export publishes a captured source package: " + exported.error);
|
||||
const auto packaged = path_from_utf8(exported.result.at("directory").get<std::string>());
|
||||
const auto package_manifest = read_json(packaged / "manifest.json");
|
||||
check(scripting::loadLuaProject(packaged).fingerprint ==
|
||||
package_manifest.at("lua_fingerprint").get<std::string>() &&
|
||||
package_manifest.at("lua_enabled") == true &&
|
||||
read_json(packaged / "Notices/dependencies.json").contains("lua") &&
|
||||
fs::is_regular_file(packaged / "Notices/lua/LICENSE.txt"),
|
||||
"Export contains source, fingerprint and the selected Lua runtime's license");
|
||||
check(!fs::exists(packaged / "schema.json") &&
|
||||
!fs::exists(packaged / "faset_schema_exporter") &&
|
||||
!fs::exists(packaged / "faset_schema_exporter.exe") &&
|
||||
!fs::exists(packaged / ".luarc.json") &&
|
||||
!fs::exists(packaged / "Scripts/Gameplay.cpp"),
|
||||
"Runtime export omits schema tools, editor configuration and C++ source");
|
||||
std::size_t packaged_sources{};
|
||||
for (const auto& file : package_manifest.at("files"))
|
||||
if (file.at("path").get<std::string>().starts_with("Scripts/")) {
|
||||
++packaged_sources;
|
||||
check(sha256_file(packaged / path_from_utf8(file.at("path").get<std::string>())) ==
|
||||
file.at("sha256").get<std::string>(),
|
||||
"Every packaged source hash matches the export manifest");
|
||||
}
|
||||
check(packaged_sources == 2, "Entry and require module both appear in export provenance");
|
||||
project.erase("scripting");
|
||||
atomic_write_json(config.project_root / "project.faset.json", project);
|
||||
atomic_write(config.project_root / "Scripts/Gameplay.cpp", "// C++ fixture\n");
|
||||
atomic_write(config.project_root / "Scripts/Gameplay.hpp", "// C++ fixture\n");
|
||||
const auto cpp_export = builds.wait(builds.start_export(scene, root / "cpp-export"));
|
||||
check(cpp_export.state == "succeeded", "C++ export still succeeds: " + cpp_export.error);
|
||||
const auto cpp_package =
|
||||
path_from_utf8(cpp_export.result.at("directory").get<std::string>());
|
||||
check(!fs::exists(cpp_package / "Scripts") &&
|
||||
!fs::exists(cpp_package / "project.faset.json") &&
|
||||
!read_json(cpp_package / "Notices/dependencies.json").contains("lua") &&
|
||||
read_json(config.project_root / "configure-fixture.json").back() ==
|
||||
"-DFASET_ENABLE_LUA=OFF",
|
||||
"Removing Lua declarations drops scripts, notices and the Lua link dependency");
|
||||
std::cout << "Valid v2 schema and atomic rejection of " << invalid.size()
|
||||
<< " malformed metadata generations passed\n";
|
||||
<< " malformed metadata generations; Lua snapshots and export contracts passed\n";
|
||||
fs::remove_all(root);
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
|
||||
@@ -7,10 +7,33 @@ namespace fs = std::filesystem;
|
||||
using namespace faset;
|
||||
int tool_main(int argc, char** argv) {
|
||||
try {
|
||||
if (argc == 3 && std::string_view(argv[1]) == "--output") {
|
||||
if (argc >= 3 && std::string_view(argv[1]) == "--output") {
|
||||
if (argc == 5 && std::string_view(argv[3]) == "--project") {
|
||||
const auto snapshot = path_from_utf8(argv[4]);
|
||||
const auto project = read_json(snapshot / "project.faset.json");
|
||||
for (const auto& name : project.at("scripting").at("lua").at("scripts"))
|
||||
if (!fs::is_regular_file(snapshot / path_from_utf8(name.get<std::string>())))
|
||||
throw std::runtime_error(
|
||||
"Schema exporter did not receive captured sources");
|
||||
atomic_write_json("exporter-project-fixture.json", project);
|
||||
if (fs::exists("mutate-lua-during-build"))
|
||||
atomic_write("Scripts/main.lua", "-- changed while schema was exporting\n");
|
||||
if (fs::exists("mutate-lua-snapshot"))
|
||||
atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n");
|
||||
}
|
||||
atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
|
||||
return 0;
|
||||
}
|
||||
if (argc >= 2 && std::string_view(argv[1]) == "--validate") {
|
||||
if (fs::exists("project.faset.json")) {
|
||||
const auto manifest = read_json("project.faset.json");
|
||||
for (const auto& name : manifest.at("scripting").at("lua").at("scripts"))
|
||||
if (!fs::is_regular_file(path_from_utf8(name.get<std::string>())))
|
||||
throw std::runtime_error("Packaged Player is missing a Lua entry source");
|
||||
}
|
||||
std::cout << "Native packaging fixture validated\n";
|
||||
return 0;
|
||||
}
|
||||
if (argc > 2 && std::string_view(argv[1]) == "--build")
|
||||
return 0;
|
||||
fs::path build;
|
||||
@@ -19,8 +42,15 @@ int tool_main(int argc, char** argv) {
|
||||
build = path_from_utf8(argv[i + 1]);
|
||||
if (build.empty())
|
||||
throw std::runtime_error("Fixture expects CMake configure or SchemaExporter arguments");
|
||||
Json arguments = Json::array();
|
||||
for (int i = 1; i < argc; ++i)
|
||||
arguments.push_back(argv[i]);
|
||||
atomic_write_json("configure-fixture.json", arguments);
|
||||
fs::create_directories(build / "shaders");
|
||||
atomic_write(build / "CMakeCache.txt", "Native schema publication fixture\n");
|
||||
for (const auto* name : {"sdl3", "entt", "box2d", "box3d", "json", "stb"})
|
||||
atomic_write(build / "_deps" / (std::string(name) + "-src") / "LICENSE.txt",
|
||||
"Synthetic dependency notice for packaging tests only.\n");
|
||||
const auto self = fs::absolute(path_from_utf8(argv[0]));
|
||||
#ifdef _WIN32
|
||||
constexpr auto suffix = ".exe";
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#ifndef _WIN32
|
||||
@@ -41,6 +42,87 @@ Json scene(int dimension) {
|
||||
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
|
||||
{"instances", Json::array()}};
|
||||
}
|
||||
void lua_project_contracts(const fs::path& root) {
|
||||
fs::create_directories(root);
|
||||
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
|
||||
Json manifest{{"format", "faset.project"}, {"version", 1}};
|
||||
const auto save_manifest = [&] { atomic_write_json(root / "project.faset.json", manifest); };
|
||||
save_manifest();
|
||||
require(!scripting::loadLuaProject(root).enabled(), "C++ manifest requires no Lua sources");
|
||||
atomic_write(root / "Scripts/main.lua", "return {value = 1}\n");
|
||||
atomic_write(root / "Scripts/lib/util.lua", "return {answer = 42}\n");
|
||||
atomic_write(root / "Scripts/Gameplay.cpp", "// Not a Lua module\n");
|
||||
manifest["scripting"]["lua"]["scripts"] = Json::array({"Scripts/main.lua"});
|
||||
save_manifest();
|
||||
const auto original = scripting::loadLuaProject(root);
|
||||
require(original.enabled() && original.sources.size() == 2 && original.fingerprint.size() == 64,
|
||||
"Capture entry script and transitive module candidates, not C++");
|
||||
require(scripting::loadLuaProject(root).fingerprint == original.fingerprint,
|
||||
"Lua fingerprint is deterministic");
|
||||
atomic_write(root / "Scripts/lib/util.lua", "return {answer = 43}\n");
|
||||
require(scripting::loadLuaProject(root).fingerprint != original.fingerprint,
|
||||
"Changes to non-entry require modules invalidate the Lua fingerprint");
|
||||
const auto target = root.parent_path() / "lua-snapshot";
|
||||
scripting::writeLuaSources(original, target);
|
||||
atomic_write_json(target / "project.faset.json", manifest);
|
||||
require(scripting::loadLuaProject(target).fingerprint == original.fingerprint &&
|
||||
read_text(target / "Scripts/lib/util.lua") == "return {answer = 42}\n",
|
||||
"Publishing writes captured bytes rather than rereading live scripts");
|
||||
const auto valid_manifest = manifest;
|
||||
auto rejected = [&](const auto& operation) {
|
||||
try {
|
||||
operation();
|
||||
} catch (const std::exception&) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const auto& entries :
|
||||
{Json::array({"Scripts/main.lua", "Scripts/main.lua"}),
|
||||
Json::array({"Scripts/../main.lua"}), Json::array({"Scripts/missing.lua"}),
|
||||
Json::array({"Scripts/Gameplay.cpp"}), Json::array({"/Scripts/main.lua"}),
|
||||
Json::array({"Scripts\\main.lua"}), Json::array({"Scripts/./main.lua"}),
|
||||
Json::array({"Scripts//main.lua"}), Json::array({"C:/Scripts/main.lua"}),
|
||||
Json::array({12}), Json("Scripts/main.lua")}) {
|
||||
manifest["scripting"]["lua"]["scripts"] = entries;
|
||||
save_manifest();
|
||||
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
|
||||
"Lua rejects malformed, duplicate, missing and escaping entry paths");
|
||||
}
|
||||
manifest = valid_manifest;
|
||||
manifest["scripting"]["lua"]["scripts"].push_back("Scripts/lib/util.lua");
|
||||
save_manifest();
|
||||
const auto with_second_entry = scripting::loadLuaProject(root).fingerprint;
|
||||
manifest["scripting"]["lua"]["scripts"] =
|
||||
Json::array({"Scripts/lib/util.lua", "Scripts/main.lua"});
|
||||
save_manifest();
|
||||
require(scripting::loadLuaProject(root).fingerprint != with_second_entry,
|
||||
"Entry order is part of the source fingerprint");
|
||||
manifest = valid_manifest;
|
||||
save_manifest();
|
||||
atomic_write(root / "Scripts/too-large.lua", std::string(1024 * 1024 + 1, ' '));
|
||||
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
|
||||
"Source size is bounded before code execution");
|
||||
fs::remove(root / "Scripts/too-large.lua");
|
||||
auto corrupt = original;
|
||||
corrupt.sources["../outside.lua"] = "return {}";
|
||||
require(rejected([&] { scripting::writeLuaSources(corrupt, target); }),
|
||||
"Writing an externally supplied snapshot validates paths too");
|
||||
std::error_code symlink_error;
|
||||
fs::create_symlink(root / "Scripts/main.lua", root / "Scripts/link.lua", symlink_error);
|
||||
if (!symlink_error) {
|
||||
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
|
||||
"Lua source snapshots reject even in-project symlink aliases");
|
||||
fs::remove(root / "Scripts/link.lua");
|
||||
fs::create_directory_symlink(root / "Scripts", target / "linked", symlink_error);
|
||||
if (!symlink_error)
|
||||
require(rejected([&] { scripting::writeLuaSources(original, target / "linked"); }),
|
||||
"Snapshot destination root cannot be a symlink");
|
||||
}
|
||||
manifest["scripting"]["lua"]["scripts"] = Json::array();
|
||||
save_manifest();
|
||||
require(!scripting::loadLuaProject(root).enabled(), "Empty Lua declaration links no Lua VM");
|
||||
}
|
||||
int integration(const fs::path& root) {
|
||||
fs::create_directories(root);
|
||||
editor::BuildConfig config;
|
||||
@@ -296,6 +378,7 @@ int test_main(int argc, char** argv) {
|
||||
fs::path temporary = fs::temp_directory_path() / path_from_utf8("Faset Café 世界 " + new_id());
|
||||
try {
|
||||
fs::create_directories(temporary);
|
||||
lua_project_contracts(temporary / "lua-project");
|
||||
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
|
||||
const auto executable = temporary / original_executable.filename();
|
||||
fs::copy_file(original_executable, executable);
|
||||
|
||||
+54
-2
@@ -1,15 +1,28 @@
|
||||
#include <chrono>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
|
||||
#define CHECK(x) \
|
||||
do { \
|
||||
if (!(x)) \
|
||||
throw std::runtime_error("Check failed: " #x); \
|
||||
} while (false)
|
||||
int main() {
|
||||
int test_main(int argc, char** argv) {
|
||||
if (argc > 2 && std::string(argv[1]) == "--detached-child") {
|
||||
faset::Json arguments = faset::Json::array();
|
||||
for (int i = 3; i < argc; ++i)
|
||||
arguments.push_back(argv[i]);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
faset::atomic_write_json(
|
||||
faset::path_from_utf8(argv[2]),
|
||||
{{"args", arguments}, {"cwd", faset::path_to_utf8(std::filesystem::current_path())}});
|
||||
return 0;
|
||||
}
|
||||
const auto directory =
|
||||
std::filesystem::temp_directory_path() / ("faset-core-" + faset::new_id());
|
||||
try {
|
||||
@@ -85,9 +98,39 @@ int main() {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
const auto external_result = directory / faset::path_from_utf8("Editor 世界.json");
|
||||
const std::vector<std::string> arguments = {
|
||||
faset::path_to_utf8(std::filesystem::absolute(faset::path_from_utf8(argv[0]))),
|
||||
"--detached-child",
|
||||
faset::path_to_utf8(external_result),
|
||||
"space argument",
|
||||
"quote\"backslash\\",
|
||||
"$(touch not-executed); & |",
|
||||
"",
|
||||
"Café 世界 Привет 😀"};
|
||||
faset::launch_detached(arguments, directory);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
while (!std::filesystem::exists(external_result) &&
|
||||
std::chrono::steady_clock::now() < deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
CHECK(std::filesystem::is_regular_file(external_result));
|
||||
const auto external = faset::read_json(external_result);
|
||||
CHECK(external.at("args") ==
|
||||
faset::Json::array({"space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", "", "Café 世界 Привет 😀"}));
|
||||
CHECK(std::filesystem::equivalent(
|
||||
faset::path_from_utf8(external.at("cwd").get<std::string>()), directory));
|
||||
CHECK(!std::filesystem::exists(directory / "not-executed"));
|
||||
rejected = false;
|
||||
try {
|
||||
faset::launch_detached({"faset-nonexistent-editor-" + faset::new_id()}, directory);
|
||||
} catch (const std::exception&) {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
|
||||
"boundaries passed\n";
|
||||
"boundaries and detached literal-argv launch passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||
@@ -95,3 +138,12 @@ int main() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
return faset::run_utf8_main(argc, argv, test_main);
|
||||
}
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
return test_main(argc, argv);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -55,6 +55,8 @@ int main() {
|
||||
}
|
||||
auto external = session.project();
|
||||
external["custom_tool"] = {{"keep", true}};
|
||||
external["scripting"] = {{"lua", {{"scripts", Json::array()}}}};
|
||||
external["editor"] = {{"script_editor", {"zed", "{file}"}}};
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}},
|
||||
"revision.conflict");
|
||||
@@ -64,6 +66,48 @@ int main() {
|
||||
{"settings", {{"name", "Preserved"}}}});
|
||||
require(changed.at("settings").at("custom_tool").at("keep") == true, "test",
|
||||
"Saving settings erased unknown project metadata");
|
||||
require(changed.at("settings").at("scripting") == external.at("scripting") &&
|
||||
changed.at("settings").at("editor") == external.at("editor"),
|
||||
"test", "Project settings erased Lua configuration or external editor command");
|
||||
const auto setup = commands.call("faset_lua_setup", Json::object());
|
||||
require(setup.at("configuration_created") == true &&
|
||||
std::filesystem::is_regular_file(root / ".faset/lua/faset.lua") &&
|
||||
read_json(root / ".luarc.json").at("runtime.version") == "Lua 5.4",
|
||||
"test", "LuaLS setup did not install annotations and configuration");
|
||||
const Json custom_luarc = {{"runtime.version", "Lua 5.4"},
|
||||
{"workspace.library", {"Custom/Lua"}},
|
||||
{"custom_setting", true}};
|
||||
atomic_write_json(root / ".luarc.json", custom_luarc);
|
||||
require(commands.call("faset_lua_setup", Json::object()).at("configuration_created") ==
|
||||
false &&
|
||||
read_json(root / ".luarc.json") == custom_luarc,
|
||||
"test", "LuaLS setup overwrote the user's configuration");
|
||||
auto reject_command = [&](const std::string& name, const Json& args,
|
||||
const std::string& code) {
|
||||
try {
|
||||
commands.call(name, args);
|
||||
} catch (const Error& error) {
|
||||
require(error.json().at("code") == code, "test",
|
||||
"Unexpected error for invalid Lua editor command");
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("Invalid Lua editor command was accepted");
|
||||
};
|
||||
reject_command("faset_lua_refresh", Json::object(), "lua.disabled");
|
||||
reject_command("faset_lua_reload", Json::object(), "play.not_running");
|
||||
reject_command("faset_script_open", {{"path", "Scripts/Gameplay.cpp"}}, "lua.source");
|
||||
atomic_write(root / "Scripts/behavior.lua", "return faset.behavior { id = 'game.test' }\n");
|
||||
reject_command("faset_script_open",
|
||||
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
|
||||
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
|
||||
"lua.editor");
|
||||
auto bad_lua_project = session.project();
|
||||
bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}};
|
||||
atomic_write_json(root / "project.faset.json", bad_lua_project);
|
||||
const auto invalid_lua_status = commands.call("faset_schema_status", Json::object());
|
||||
require(invalid_lua_status.at("stale") == true &&
|
||||
!invalid_lua_status.at("error").get<std::string>().empty(),
|
||||
"test", "Invalid Lua manifest did not mark schema stale with diagnostics");
|
||||
external["version"] = 999;
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
bool rejected = false;
|
||||
@@ -74,8 +118,8 @@ int main() {
|
||||
}
|
||||
require(rejected, "test", "Future project version was reinterpreted");
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "Project settings validation, save, external revision conflicts and opaque "
|
||||
"metadata passed\n";
|
||||
std::cout << "Project settings validation, revision conflicts, opaque metadata, LuaLS "
|
||||
"configuration preservation and Lua editor commands passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nFixture retained at " << root << '\n';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""CPU-only schema-export and Player validation contracts for optional Lua."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--exporter", required=True)
|
||||
parser.add_argument("--player")
|
||||
parser.add_argument("--disabled", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def run(executable, *arguments, success=True):
|
||||
result = subprocess.run([executable, *map(str, arguments)], capture_output=True,
|
||||
text=True, encoding="utf-8", timeout=20)
|
||||
assert (result.returncode == 0) == success, (result.stdout, result.stderr)
|
||||
return result
|
||||
|
||||
|
||||
def behavior(type_id="test.lua.cli", callback='error("LUA_CALLBACK_MUST_NOT_RUN")'):
|
||||
return f'''local behavior = faset.behavior {{
|
||||
id = "{type_id}", version = 1, name = "CLI behavior",
|
||||
fields = {{ speed = {{ type = "number", default = 3, min = 0 }} }}
|
||||
}}
|
||||
function behavior:on_start() {callback} end
|
||||
return behavior
|
||||
'''
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-lua-cli-") as temporary:
|
||||
root = Path(temporary) / "Lua Café 世界"
|
||||
scripts = root / "Scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
manifest = root / "project.faset.json"
|
||||
manifest.write_text(json.dumps({"format": "faset.project", "version": 1}), encoding="utf-8")
|
||||
native = json.loads(run(args.exporter).stdout)
|
||||
assert json.loads(run(args.exporter, "--project", root).stdout) == native
|
||||
manifest.write_text(json.dumps({"format": "faset.project", "version": 1,
|
||||
"scripting": {"lua": {"scripts": ["Scripts/cli.lua"]}}}),
|
||||
encoding="utf-8")
|
||||
source = scripts / "cli.lua"
|
||||
source.write_text(behavior(), encoding="utf-8")
|
||||
if args.disabled:
|
||||
result = run(args.exporter, "--project", root, success=False)
|
||||
assert "without Lua support" in result.stderr, result.stderr
|
||||
if args.player:
|
||||
run(args.player, "--help")
|
||||
print("Lua-disabled schema exporter accepts C++ projects and rejects Lua clearly")
|
||||
else:
|
||||
exported = root / "schema.json"
|
||||
run(args.exporter, "--project", root, "--output", exported)
|
||||
merged = json.loads(exported.read_text(encoding="utf-8"))
|
||||
assert merged["format"] == "faset.schema" and merged["version"] == 1
|
||||
by_id = {item["id"]: item for item in merged["types"]}
|
||||
assert len(by_id) == len(native["types"]) + 1
|
||||
assert by_id["test.lua.cli"]["fields"]["speed"]["default"] == 3
|
||||
for item in native["types"]:
|
||||
assert by_id[item["id"]] == item
|
||||
|
||||
# Lua lifecycle errors do not execute in the schema exporter or --validate.
|
||||
scene = root / "scene.scene.json"
|
||||
scene.write_text(json.dumps({
|
||||
"format": "faset.scene", "version": 1, "dimension": 2,
|
||||
"id": "lua-cli-scene", "name": "Lua CLI", "instances": [],
|
||||
"entities": [{"id": "lua-object", "name": "Lua", "parent": None,
|
||||
"components": [{"id": "lua-behavior", "type": "test.lua.cli",
|
||||
"version": 1, "fields": {"speed": 3}}]}]
|
||||
}), encoding="utf-8")
|
||||
if args.player:
|
||||
for project_arguments in [("--project", root), ()]:
|
||||
result = run(args.player, "--scene", scene, "--validate", *project_arguments)
|
||||
assert json.loads(result.stdout)["validated"]
|
||||
assert "LUA_CALLBACK_MUST_NOT_RUN" not in result.stderr
|
||||
valid_scene = scene.read_text(encoding="utf-8")
|
||||
invalid_scene = json.loads(valid_scene)
|
||||
for invalid_speed in ["fast", -1]:
|
||||
invalid_scene["entities"][0]["components"][0]["fields"]["speed"] = invalid_speed
|
||||
scene.write_text(json.dumps(invalid_scene), encoding="utf-8")
|
||||
result = run(args.player, "--scene", scene, "--validate", "--project", root,
|
||||
success=False)
|
||||
assert "speed" in result.stderr, result.stderr
|
||||
assert "LUA_CALLBACK_MUST_NOT_RUN" not in result.stderr, result.stderr
|
||||
scene.write_text(valid_scene, encoding="utf-8")
|
||||
run(args.player, "--scene", scene, "--validate", "--watch-lua", success=False)
|
||||
|
||||
# A failed schema candidate never overwrites a previously published file.
|
||||
previous = exported.read_bytes()
|
||||
source.write_text("this is invalid Lua!", encoding="utf-8")
|
||||
result = run(args.exporter, "--project", root, "--output", exported, success=False)
|
||||
assert "cli.lua" in result.stderr, result.stderr
|
||||
assert exported.read_bytes() == previous
|
||||
source.write_text(behavior("faset.transform"), encoding="utf-8")
|
||||
run(args.exporter, "--project", root, success=False)
|
||||
if native["types"]:
|
||||
source.write_text(behavior(native["types"][0]["id"]), encoding="utf-8")
|
||||
run(args.exporter, "--project", root, success=False)
|
||||
source.write_text("while true do end", encoding="utf-8")
|
||||
run(args.exporter, "--project", root, success=False)
|
||||
source.write_text(behavior(), encoding="utf-8")
|
||||
assert json.loads(run(args.exporter, "--project", root).stdout) == merged
|
||||
print("Lua schema merge, lifecycle-free validation, failure isolation and budgets passed")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Production Player reloads Lua atomically and keeps the old world on failure."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def behavior(marker, fail=False):
|
||||
start = 'error("LUA_RELOAD_START_FAILED")' if fail else f'faset.log("LUA_START_{marker}")'
|
||||
return f'''local behavior = faset.behavior {{
|
||||
id = "test.lua.reload", version = 1, name = "Reload test", fields = {{}}
|
||||
}}
|
||||
function behavior:on_start() {start} end
|
||||
function behavior:on_destroy() faset.log("LUA_DESTROY_{marker}") end
|
||||
return behavior
|
||||
'''
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-lua-reload-") as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "Scripts").mkdir()
|
||||
(root / "project.faset.json").write_text(json.dumps({
|
||||
"format": "faset.project", "version": 1,
|
||||
"scripting": {"lua": {"scripts": ["Scripts/reload.lua"]}}
|
||||
}), encoding="utf-8")
|
||||
source = root / "Scripts/reload.lua"
|
||||
source.write_text(behavior("A"), encoding="utf-8")
|
||||
scene = root / "scene.scene.json"
|
||||
scene.write_text(json.dumps({
|
||||
"format": "faset.scene", "version": 1, "id": "reload-scene", "dimension": 2,
|
||||
"entities": [{"id": "object", "name": "Lua", "parent": None,
|
||||
"components": [{"id": "lua", "type": "test.lua.reload",
|
||||
"version": 1, "fields": {}}]}]
|
||||
}), encoding="utf-8")
|
||||
control = root / "control.json"
|
||||
logs = []
|
||||
process = subprocess.Popen([sys.argv[1], "--scene", str(scene), "--project", str(root),
|
||||
"--control", str(control), "--watch-lua", "--headless",
|
||||
"--frames", "10000000"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, encoding="utf-8")
|
||||
|
||||
def collect():
|
||||
for line in process.stderr:
|
||||
logs.append(line)
|
||||
|
||||
reader = threading.Thread(target=collect, daemon=True)
|
||||
reader.start()
|
||||
|
||||
def wait_for(marker, count=1):
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
if "".join(logs).count(marker) >= count:
|
||||
return
|
||||
assert process.poll() is None, "".join(logs)
|
||||
time.sleep(.025)
|
||||
raise AssertionError((marker, "".join(logs)))
|
||||
|
||||
def send(sequence, command):
|
||||
staging = root / "control.tmp"
|
||||
staging.write_text(json.dumps({"sequence": sequence, "command": command}),
|
||||
encoding="utf-8")
|
||||
staging.replace(control)
|
||||
|
||||
try:
|
||||
wait_for("LUA_START_A")
|
||||
source.write_text("invalid Lua syntax !", encoding="utf-8")
|
||||
wait_for("Lua reload rejected;")
|
||||
time.sleep(.65)
|
||||
assert "".join(logs).count("Lua reload rejected;") == 1, logs
|
||||
assert "LUA_DESTROY_A" not in "".join(logs), logs
|
||||
source.write_text(behavior("BAD", fail=True), encoding="utf-8")
|
||||
wait_for("LUA_RELOAD_START_FAILED")
|
||||
assert "LUA_DESTROY_A" not in "".join(logs), logs
|
||||
source.write_text(behavior("B"), encoding="utf-8")
|
||||
wait_for("LUA_START_B")
|
||||
assert "".join(logs).count("LUA_DESTROY_A") == 1, logs
|
||||
send(1, "reload-lua")
|
||||
wait_for("LUA_START_B", count=2)
|
||||
assert "".join(logs).count("LUA_DESTROY_B") == 1, logs
|
||||
send(2, "stop")
|
||||
process.wait(timeout=15)
|
||||
reader.join(timeout=2)
|
||||
assert process.returncode == 0, "".join(logs)
|
||||
assert "".join(logs).count("LUA_DESTROY_B") == 2, logs
|
||||
assert json.loads(process.stdout.read())["frames"] > 0
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=3)
|
||||
reader.join(timeout=2)
|
||||
print("Lua Player watched/explicit reload, startup rollback and shutdown logs passed")
|
||||
@@ -0,0 +1,215 @@
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/scripting/LuaModule.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
using namespace faset::runtime;
|
||||
using namespace faset::scripting;
|
||||
using Json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
template <class Function> void rejects(Function function, const char* message) {
|
||||
bool rejected{};
|
||||
try {
|
||||
function();
|
||||
} catch (const std::exception&) {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected, message);
|
||||
}
|
||||
LuaProject project(std::string source) {
|
||||
LuaProject result;
|
||||
result.scripts = {"Scripts/safety.lua"};
|
||||
result.sources.emplace(result.scripts.front(), std::move(source));
|
||||
return result;
|
||||
}
|
||||
Json scene(unsigned count = 1) {
|
||||
Json entities = Json::array();
|
||||
for (unsigned i = 0; i < count; ++i)
|
||||
entities.push_back({{"id", "actor" + std::to_string(i)},
|
||||
{"name", "Safety actor"},
|
||||
{"parent", nullptr},
|
||||
{"components", Json::array({{{"id", "transform"},
|
||||
{"type", "faset.transform"},
|
||||
{"version", 1},
|
||||
{"fields", Json::object()}},
|
||||
{{"id", "behavior"},
|
||||
{"type", "test.safety"},
|
||||
{"version", 1},
|
||||
{"fields", {{"fail", i == 0}}}}})}});
|
||||
return {{"format", "faset.scene"}, {"version", 1}, {"dimension", 2}, {"entities", entities}};
|
||||
}
|
||||
void depthAndMetatables() {
|
||||
LuaModule lua(project(R"lua(
|
||||
assert(setmetatable == nil)
|
||||
assert(getmetatable("") == "string")
|
||||
local nested = 7
|
||||
for i = 1, 24 do nested = {nested} end
|
||||
local B = faset.behavior {
|
||||
id = "test.safety",
|
||||
fields = { nested = { type = "any", default = nested } }
|
||||
}
|
||||
function B:on_start()
|
||||
assert(getmetatable(self) == "faset.BehaviorInstance")
|
||||
assert(getmetatable(self.entity) == "faset.Entity")
|
||||
local value = self.fields.nested
|
||||
for i = 1, 24 do value = value[1] end
|
||||
assert(value == 7)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene());
|
||||
check(world.diagnostics().empty(), "deep supported JSON uses reserved Lua stack slots");
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local nested = 7
|
||||
for i = 1, 40 do nested = {nested} end
|
||||
return faset.behavior {id="test.safety",fields={value={type="any",default=nested}}}
|
||||
)lua"));
|
||||
},
|
||||
"over-depth JSON rejects cleanly");
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local cyclic = {}
|
||||
cyclic.self = cyclic
|
||||
return faset.behavior {id="test.safety",fields={value={type="any",default=cyclic}}}
|
||||
)lua"));
|
||||
},
|
||||
"cyclic JSON rejects cleanly");
|
||||
}
|
||||
void jsonFanout() {
|
||||
// Lua owns only one 1-MiB string. Copying aliases into native JSON must not
|
||||
// evade the host's aggregate byte limit and expand into an enormous tree.
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local text = string.rep("x", 1024 * 1024)
|
||||
local aliases = {}
|
||||
for i = 1, 64 do aliases[i] = text end
|
||||
return faset.behavior {id="test.safety",fields={value={type="array",default=aliases}}}
|
||||
)lua"));
|
||||
},
|
||||
"JSON string alias fanout is bounded before native copies");
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local key = string.rep("x", 1024 * 1024)
|
||||
local aliases = {}
|
||||
for i = 1, 64 do aliases[i] = {[key]=true} end
|
||||
return faset.behavior {id="test.safety",fields={value={type="array",default=aliases}}}
|
||||
)lua"));
|
||||
},
|
||||
"JSON key alias fanout is bounded before native copies");
|
||||
}
|
||||
void structuralBudgets() {
|
||||
for (const char* body : {"for i=1,2048 do self.entity:destroy() end",
|
||||
"local text=string.rep('x',1024*1024)\n"
|
||||
"for i=1,64 do self.entity:add_component {id='data'..i,type='data'..i,"
|
||||
"version=1,fields={text=text}} end"}) {
|
||||
LuaModule lua(project(std::string("local B=faset.behavior{id='test.safety',fields={}}\n"
|
||||
"function B:on_start()\n") +
|
||||
body + "\nend\nreturn B"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene());
|
||||
check(world.diagnostics().size() == 1,
|
||||
"native structural queue has count and byte budgets");
|
||||
world.advance(0.001);
|
||||
check(world.diagnostics().size() == 1,
|
||||
"structural-budget error disables only that instance");
|
||||
}
|
||||
}
|
||||
void repeatedMemoryFailures() {
|
||||
LuaLimits limits;
|
||||
limits.memoryBytes = 256 * 1024;
|
||||
for (unsigned repetition = 0; repetition < 32; ++repetition) {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.safety", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then
|
||||
self.state.too_large = string.rep("x", 1024 * 1024)
|
||||
else
|
||||
local value = self.entity:transform()
|
||||
value.position.x = value.position.x + 1
|
||||
self.entity:set_transform(value)
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(2));
|
||||
for (unsigned frame = 0; frame < 20; ++frame)
|
||||
world.advance(0.001);
|
||||
check(world.diagnostics().size() == 1, "OOM instance reports once across repeated frames");
|
||||
check(world.transform(world.find("actor1")).position[0] == 20,
|
||||
"OOM does not poison subsequent protected calls or Lua stack");
|
||||
}
|
||||
// VM bootstrap itself must report allocation failure, not invoke Lua panic.
|
||||
limits.memoryBytes = 1;
|
||||
rejects(
|
||||
[&] {
|
||||
LuaModule invalid(project("return faset.behavior{id='test.safety',fields={}}"), limits);
|
||||
},
|
||||
"tiny VM budget fails safely during construction");
|
||||
}
|
||||
void persistentStateMemoryFailure() {
|
||||
LuaLimits limits;
|
||||
limits.memoryBytes = 256 * 1024;
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.safety", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then
|
||||
self.state.allocations = {}
|
||||
while true do
|
||||
self.state.allocations[#self.state.allocations + 1] = string.rep("x", 1024)
|
||||
end
|
||||
else
|
||||
local value = self.entity:transform()
|
||||
value.position.x = value.position.x + 1
|
||||
self.entity:set_transform(value)
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(2));
|
||||
for (unsigned frame = 0; frame < 20; ++frame)
|
||||
world.advance(0.001);
|
||||
check(world.diagnostics().size() == 1,
|
||||
"incremental state exhaustion only disables the failed instance");
|
||||
check(world.transform(world.find("actor1")).position[0] == 20,
|
||||
"failed state is released before healthy instances run");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
depthAndMetatables();
|
||||
jsonFanout();
|
||||
structuralBudgets();
|
||||
repeatedMemoryFailures();
|
||||
persistentStateMemoryFailure();
|
||||
std::cout << "Lua safety tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Lua safety failure: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/runtime/schema.hpp>
|
||||
#include <faset/scripting/LuaModule.hpp>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace faset::runtime;
|
||||
using namespace faset::scripting;
|
||||
using Json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
void check(bool result, const char* message) {
|
||||
if (!result)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
void near(float actual, float expected, const char* message) {
|
||||
check(std::abs(actual - expected) < 0.001f, message);
|
||||
}
|
||||
template <class F> void rejects(F&& fn, const char* message) {
|
||||
bool rejected = false;
|
||||
try {
|
||||
fn();
|
||||
} catch (const std::exception&) {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected, message);
|
||||
}
|
||||
LuaProject project(std::string source) {
|
||||
LuaProject result;
|
||||
result.scripts = {"Scripts/main.lua"};
|
||||
result.sources.emplace(result.scripts.front(), std::move(source));
|
||||
return result;
|
||||
}
|
||||
Json component(const std::string& type, Json fields = Json::object()) {
|
||||
return {{"id", type + "-component"}, {"type", type}, {"version", 1}, {"fields", fields}};
|
||||
}
|
||||
Json entity(const std::string& id, const std::string& type = "test.lua",
|
||||
Json fields = Json::object()) {
|
||||
return {{"id", id},
|
||||
{"name", id},
|
||||
{"parent", nullptr},
|
||||
{"components", Json::array({component("faset.transform"), component(type, fields)})}};
|
||||
}
|
||||
Json scene(Json entities) {
|
||||
return {{"format", "faset.scene"},
|
||||
{"version", 1},
|
||||
{"id", "lua-tests"},
|
||||
{"dimension", 2},
|
||||
{"entities", std::move(entities)},
|
||||
{"instances", Json::array()}};
|
||||
}
|
||||
bool contains(const std::vector<std::string>& lines, const std::string& text) {
|
||||
return std::any_of(lines.begin(), lines.end(),
|
||||
[&](const auto& line) { return line.find(text) != std::string::npos; });
|
||||
}
|
||||
void defaultsAndIsolation() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.lua", version = 1, name = "Lua test",
|
||||
fields = {
|
||||
speed = { type = "number", default = 2, min = 0, max = 10 },
|
||||
enabled = { type = "boolean", default = true },
|
||||
label = { type = "string", default = "default" }
|
||||
}
|
||||
}
|
||||
function B:on_start()
|
||||
assert(self.fields.enabled and self.fields.label == "default")
|
||||
self.state.count = 0
|
||||
end
|
||||
function B:update(dt)
|
||||
assert(dt > 0)
|
||||
self.state.count = self.state.count + 1
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = self.state.count * self.fields.speed
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
const auto metadata = lua.schema();
|
||||
check(metadata.is_array() && metadata.size() == 1, "one Lua behavior exports one schema");
|
||||
check(metadata[0]["fields"]["speed"]["default"] == 2, "schema retains defaults");
|
||||
check(metadata[0]["id"] == "test.lua", "schema retains stable TypeId");
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
auto doc =
|
||||
scene(Json::array({entity("default"), entity("override", "test.lua", {{"speed", 5}})}));
|
||||
validate_scene_schemas(doc, metadata);
|
||||
lua.validateScene(doc);
|
||||
auto invalid = doc;
|
||||
invalid["entities"][0]["components"][1]["fields"]["speed"] = "not a number";
|
||||
rejects([&] { lua.validateScene(invalid); }, "CPU validation rejects invalid Lua field type");
|
||||
invalid["entities"][0]["components"][1]["fields"]["speed"] = -1;
|
||||
rejects([&] { lua.validateScene(invalid); }, "CPU validation enforces Lua field constraints");
|
||||
const auto original = doc;
|
||||
world.load(doc);
|
||||
world.advance(1.0 / 60);
|
||||
near(world.transform(world.find("default")).position[0], 2, "Lua uses schema default");
|
||||
near(world.transform(world.find("override")).position[0], 5, "Lua uses scene override");
|
||||
world.advance(1.0 / 60);
|
||||
near(world.transform(world.find("default")).position[0], 4, "state persists per instance");
|
||||
near(world.transform(world.find("override")).position[0], 10, "instances do not share state");
|
||||
check(world.fields(world.find("default"), "test.lua").empty(),
|
||||
"defaults do not mutate stored runtime configuration");
|
||||
check(doc == original, "Lua cannot mutate authoring scene");
|
||||
check(world.diagnostics().empty(), "valid Lua behavior produces no diagnostics");
|
||||
}
|
||||
void lifecycleAndHandles() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local previous
|
||||
local B = faset.behavior { id = "test.lua", version = 1, fields = {} }
|
||||
function B:on_start()
|
||||
if previous then assert(not previous:valid()) end
|
||||
assert(self.entity:valid())
|
||||
assert(faset.find("missing") == nil)
|
||||
assert(faset.find("actor") == self.entity)
|
||||
previous = self.entity
|
||||
faset.log("phase:start")
|
||||
end
|
||||
function B:fixed_update(dt)
|
||||
assert(dt > 0)
|
||||
local input = faset.input()
|
||||
assert(input.horizontal == 1 and input.vertical == -1)
|
||||
assert(input.jump_pressed and input.interact_pressed)
|
||||
faset.log("phase:fixed")
|
||||
end
|
||||
function B:update(dt) faset.log("phase:update") end
|
||||
function B:late_update(dt)
|
||||
local pose = self.entity:presentation()
|
||||
pose.position.x = 17
|
||||
self.entity:set_presentation(pose)
|
||||
faset.log("phase:late")
|
||||
end
|
||||
function B:on_destroy()
|
||||
assert(self.entity:valid())
|
||||
faset.log("phase:destroy")
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
const auto doc = scene(Json::array({entity("actor")}));
|
||||
world.load(doc);
|
||||
world.advance(1.0 / 60, {1, -1, true, true});
|
||||
near(world.presentation(world.find("actor")).position[0], 17,
|
||||
"late_update can write presentation transform");
|
||||
near(world.transform(world.find("actor")).position[0], 0,
|
||||
"presentation write does not mutate simulation transform");
|
||||
world.clear();
|
||||
const auto lines = lua.takeLogs();
|
||||
const std::vector<std::string> phases = {"phase:start", "phase:fixed", "phase:update",
|
||||
"phase:late", "phase:destroy"};
|
||||
check(lines.size() == phases.size(), "each lifecycle callback runs exactly once");
|
||||
for (std::size_t i = 0; i < phases.size(); ++i)
|
||||
check(lines[i].find(phases[i]) != std::string::npos, "Lua lifecycle follows runtime order");
|
||||
check(lua.takeLogs().empty(), "taking logs drains the queue");
|
||||
world.load(doc);
|
||||
check(world.diagnostics().empty(), "old Lua handles stay stale across world reload");
|
||||
world.clear();
|
||||
}
|
||||
void moduleLifetime() {
|
||||
Runtime world;
|
||||
{
|
||||
LuaModule temporary(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:update(dt)
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = 23
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
temporary.registerBehaviors(world);
|
||||
}
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
world.advance(0.01);
|
||||
near(world.transform(world.find("actor")).position[0], 23,
|
||||
"registered callbacks retain VM after LuaModule facade destruction");
|
||||
world.clear();
|
||||
}
|
||||
void modulesAndSandbox() {
|
||||
auto snapshot = project(R"lua(
|
||||
assert(io == nil and os == nil and debug == nil)
|
||||
assert(load == nil and loadfile == nil and dofile == nil)
|
||||
assert(pcall == nil and xpcall == nil and coroutine == nil)
|
||||
local util = require("util.math")
|
||||
assert(require("util.math") == util)
|
||||
local directory = require("directory")
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = util.answer + directory.answer
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua");
|
||||
snapshot.sources["Scripts/util/math.lua"] = "return { answer = 40 }";
|
||||
snapshot.sources["Scripts/directory/init.lua"] = "return { answer = 2 }";
|
||||
LuaModule lua(snapshot);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
near(world.transform(world.find("actor")).position[0], 42, "require loads captured modules");
|
||||
check(world.diagnostics().empty(), "sandboxed standard operations succeed");
|
||||
auto missing = project("return require('missing')");
|
||||
rejects([&] { LuaModule invalid(missing); }, "missing modules fail loading");
|
||||
auto cycle = project("return require('cycle')");
|
||||
cycle.sources["Scripts/cycle.lua"] = "return require('cycle')";
|
||||
rejects([&] { LuaModule invalid(cycle); }, "cyclic require fails rather than looping");
|
||||
for (const auto& name : {"../escape", "/absolute", "foo/bar", "foo..bar"}) {
|
||||
const auto escape = project(std::string("return require('") + name + "')");
|
||||
rejects([&] { LuaModule invalid(escape); }, "require rejects path-like module names");
|
||||
}
|
||||
}
|
||||
void structuralCommands() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
self.state.ticks = 0
|
||||
self.entity:add_component {
|
||||
id = "extra-component", type = "test.data", version = 1, fields = { value = 7 }
|
||||
}
|
||||
faset.spawn {
|
||||
id = "spawned", name = "Spawned", parent = faset.null,
|
||||
components = {
|
||||
{ id = "spawned-transform", type = "faset.transform", version = 1,
|
||||
fields = { position = { 3, 4, 0 } } }
|
||||
}
|
||||
}
|
||||
assert(faset.find("spawned") == nil)
|
||||
end
|
||||
function B:fixed_update(dt)
|
||||
self.state.ticks = self.state.ticks + 1
|
||||
if self.state.ticks == 1 then
|
||||
assert(self.entity:fields("test.data").value == 7)
|
||||
self.entity:remove_component("test.data")
|
||||
local spawned = faset.find("spawned")
|
||||
assert(spawned and spawned:valid())
|
||||
assert(spawned:transform().position.x == 3)
|
||||
spawned:destroy()
|
||||
assert(spawned:valid())
|
||||
self.state.spawned = spawned
|
||||
elseif self.state.ticks == 2 then
|
||||
assert(not self.state.spawned:valid())
|
||||
assert(faset.find("spawned") == nil)
|
||||
faset.log("structural:done")
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
check(!world.find("spawned"), "Lua spawn waits for fixed-tick barrier");
|
||||
world.singleStep();
|
||||
check(bool(world.find("spawned")), "Lua spawn applies at first barrier");
|
||||
world.singleStep();
|
||||
check(!world.find("spawned"), "Lua destroy applies at next barrier");
|
||||
rejects([&] { world.fields(world.find("actor"), "test.data"); },
|
||||
"Lua remove_component applies at next barrier");
|
||||
check(contains(lua.takeLogs(), "structural:done"), "Lua observes deferred structural changes");
|
||||
check(world.diagnostics().empty(), "valid structural API calls produce no errors");
|
||||
}
|
||||
void errorsAreContained() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.lua", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then error("intentional-lua-error") end
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = pose.position.x + 1
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("bad", "test.lua", {{"fail", true}}), entity("good")})));
|
||||
world.advance(0.01);
|
||||
world.advance(0.01);
|
||||
near(world.transform(world.find("good")).position[0], 2,
|
||||
"a failed Lua instance does not disable healthy instances");
|
||||
check(world.diagnostics().size() == 1, "failed instance logs once and is disabled");
|
||||
check(contains(world.diagnostics(), "intentional-lua-error"),
|
||||
"Lua errors reach runtime diagnostics");
|
||||
check(contains(world.diagnostics(), "Scripts/main.lua"), "Lua error includes source path");
|
||||
check(contains(world.diagnostics(), "stack traceback"), "Lua error includes traceback");
|
||||
for (const auto& body :
|
||||
{"self.entity:set_presentation(self.entity:transform())", "self.entity:velocity()",
|
||||
"self.entity:set_transform({position = {x = 0/0, y = 0, z = 0}})"}) {
|
||||
LuaModule invalid(project(std::string("local B=faset.behavior{id='test.lua',fields={}}\n") +
|
||||
"function B:update(dt) " + body + " end\nreturn B"));
|
||||
Runtime separate;
|
||||
invalid.registerBehaviors(separate);
|
||||
separate.load(scene(Json::array({entity("actor")})));
|
||||
separate.advance(0.01);
|
||||
check(!separate.diagnostics().empty(), "invalid bound calls become contained Lua errors");
|
||||
}
|
||||
}
|
||||
void staleHandleAccess() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local previous
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
if previous then
|
||||
assert(not previous:valid())
|
||||
previous:transform()
|
||||
end
|
||||
previous = self.entity
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
const auto document = scene(Json::array({entity("actor")}));
|
||||
world.load(document);
|
||||
check(world.diagnostics().empty(), "initial entity handle is valid");
|
||||
world.load(document);
|
||||
check(!world.diagnostics().empty(),
|
||||
"retained userdata rejects access after session replacement");
|
||||
|
||||
LuaModule shared(project(R"lua(
|
||||
local previous
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
if previous then
|
||||
assert(not previous:valid())
|
||||
previous:transform()
|
||||
end
|
||||
previous = self.entity
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime first;
|
||||
Runtime second;
|
||||
shared.registerBehaviors(first);
|
||||
shared.registerBehaviors(second);
|
||||
first.load(document);
|
||||
second.load(document);
|
||||
check(!second.diagnostics().empty(), "userdata from another world is rejected");
|
||||
}
|
||||
void componentInstanceLifetime() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
assert(self.state.counter == nil)
|
||||
self.state.counter = 0
|
||||
faset.log("component:start")
|
||||
end
|
||||
function B:update(dt) self.state.counter = self.state.counter + 1 end
|
||||
function B:on_destroy() faset.log("component:destroy") end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
world.advance(0.01);
|
||||
auto handle = world.find("actor");
|
||||
world.removeComponent(handle, "test.lua");
|
||||
world.singleStep();
|
||||
check(world.valid(handle), "removing behavior does not remove its owner");
|
||||
world.addComponent(handle, component("test.lua"));
|
||||
world.singleStep();
|
||||
auto lines = lua.takeLogs();
|
||||
check(lines.size() == 3 && lines[0].find("component:start") != std::string::npos &&
|
||||
lines[1].find("component:destroy") != std::string::npos &&
|
||||
lines[2].find("component:start") != std::string::npos,
|
||||
"re-attaching a behavior creates fresh instance state");
|
||||
check(world.diagnostics().empty(), "component state is released on removal");
|
||||
world.clear();
|
||||
}
|
||||
void physicsAndCollision() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
local velocity = self.entity:velocity()
|
||||
velocity.x = 1
|
||||
self.entity:set_velocity(velocity)
|
||||
self.entity:apply_impulse { x = 0, y = 0.1, z = 0 }
|
||||
assert(self.entity:velocity().y > 0)
|
||||
local pose = self.entity:transform()
|
||||
pose.position.y = 2
|
||||
self.entity:teleport(pose)
|
||||
assert(not self.entity:is_grounded())
|
||||
end
|
||||
function B:on_collision(event)
|
||||
assert(event.first:valid() and event.second:valid())
|
||||
if event.began then faset.log("contact:began") end
|
||||
end
|
||||
function B:fixed_update(dt)
|
||||
if self.entity:is_grounded() then faset.log("body:grounded") end
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
auto floor = entity("floor", "test.floor");
|
||||
floor["components"][0]["fields"] = {{"position", {0, -0.5, 0}}};
|
||||
floor["components"].push_back(
|
||||
component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {10, 0.5}}}));
|
||||
auto falling = entity("actor");
|
||||
falling["components"].push_back(component("faset.rigid_body_2d"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({floor, falling})));
|
||||
for (int i = 0; i < 180; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
const auto logs = lua.takeLogs();
|
||||
check(contains(logs, "contact:began"), "Lua receives native collision event");
|
||||
check(contains(logs, "body:grounded"), "Lua sees native grounded state");
|
||||
check(world.diagnostics().empty(), "valid physics bindings produce no diagnostics");
|
||||
}
|
||||
void invalidDefinitionsAndBudgets() {
|
||||
for (const auto& source :
|
||||
{"this is not lua", "return 42", "return faset.behavior { id = '', fields = {} }",
|
||||
"return faset.behavior { id = 'faset.transform', fields = {} }",
|
||||
"return faset.behavior { id = 'test.lua', version = 0, fields = {} }",
|
||||
"return faset.behavior { id = 'test.lua', fields = { speed = { type = 'number' } } }"}) {
|
||||
rejects([&] { LuaModule invalid(project(source)); },
|
||||
"invalid behavior declaration is rejected");
|
||||
}
|
||||
auto duplicate = project("return faset.behavior { id = 'test.lua', fields = {} }");
|
||||
duplicate.scripts.push_back("Scripts/second.lua");
|
||||
duplicate.sources["Scripts/second.lua"] = duplicate.sources.begin()->second;
|
||||
rejects([&] { LuaModule invalid(duplicate); }, "duplicate behavior TypeIds are rejected");
|
||||
LuaLimits limits;
|
||||
limits.instructions = 10'000;
|
||||
rejects([&] { LuaModule invalid(project("while true do end"), limits); },
|
||||
"instruction budget bounds module evaluation");
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:update(dt) while true do end end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
world.advance(0.01);
|
||||
check(!world.diagnostics().empty(), "instruction budget bounds callback evaluation");
|
||||
const auto failures = world.diagnostics().size();
|
||||
world.advance(0.01);
|
||||
check(world.diagnostics().size() == failures, "runaway instance stays disabled");
|
||||
}
|
||||
void memoryLimits() {
|
||||
LuaLimits limits;
|
||||
limits.memoryBytes = 256 * 1024;
|
||||
rejects(
|
||||
[&] {
|
||||
LuaModule invalid(project("local oversized = string.rep('x', 1048576)\n"
|
||||
"return faset.behavior{id='test.lua',fields={}}"),
|
||||
limits);
|
||||
},
|
||||
"memory budget bounds module evaluation");
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.lua", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then
|
||||
self.state.oversized = string.rep("x", 1048576)
|
||||
else
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = pose.position.x + 1
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("bad", "test.lua", {{"fail", true}}), entity("good")})));
|
||||
world.advance(0.01);
|
||||
check(!world.diagnostics().empty(), "memory budget bounds callback allocations");
|
||||
world.advance(0.01);
|
||||
near(world.transform(world.find("good")).position[0], 2,
|
||||
"VM survives a rejected allocation and runs other instances");
|
||||
}
|
||||
#ifdef FASET_SOURCE_DIR
|
||||
void exampleSmokeTest() {
|
||||
const auto root = std::filesystem::path(FASET_SOURCE_DIR) / "examples/lua";
|
||||
const auto snapshot = loadLuaProject(root);
|
||||
LuaModule lua(snapshot);
|
||||
check(lua.schema().size() == 2, "shipped Lua example exports both behaviors");
|
||||
const auto document = faset::read_json(root / "Scenes/main.scene.json");
|
||||
validate_scene_schemas(document, lua.schema());
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(document);
|
||||
for (int i = 0; i < 120; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(world.grounded(world.find("player")), "Lua example player lands on its floor");
|
||||
world.advance(1.0 / 60, {1, 0, true, false});
|
||||
check(world.velocity(world.find("player"))[0] > 4,
|
||||
"Lua example controller applies horizontal input");
|
||||
check(world.velocity(world.find("player"))[1] > 5,
|
||||
"Lua example controller jumps from native contact");
|
||||
world.advance(1.0 / 60, {0, 0, false, true});
|
||||
near(world.transform(world.find("player")).position[0], -2,
|
||||
"Lua example interaction resets player position");
|
||||
check(world.diagnostics().empty(), "shipped Lua scripts run without diagnostics");
|
||||
}
|
||||
#endif
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
defaultsAndIsolation();
|
||||
lifecycleAndHandles();
|
||||
moduleLifetime();
|
||||
modulesAndSandbox();
|
||||
structuralCommands();
|
||||
errorsAreContained();
|
||||
staleHandleAccess();
|
||||
componentInstanceLifetime();
|
||||
physicsAndCollision();
|
||||
invalidDefinitionsAndBudgets();
|
||||
memoryLimits();
|
||||
#ifdef FASET_SOURCE_DIR
|
||||
exampleSmokeTest();
|
||||
#endif
|
||||
std::cout << "Lua: schemas, state, lifecycle, safe handles, sandbox, modules, physics, "
|
||||
"deferred commands, diagnostics, and execution limits passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Lua test failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
---@meta
|
||||
-- Language-server declarations only. Never require this file at runtime.
|
||||
|
||||
---@class FasetVec3
|
||||
---@field x number
|
||||
---@field y number
|
||||
---@field z number
|
||||
|
||||
---@class FasetTransform
|
||||
---@field position FasetVec3 Local position in metres.
|
||||
---@field rotation FasetVec3 Euler XYZ rotation in radians.
|
||||
---@field scale FasetVec3 Local scale multiplier.
|
||||
|
||||
---@class FasetInput
|
||||
---@field horizontal number
|
||||
---@field vertical number
|
||||
---@field jump_pressed boolean One-shot Space edge.
|
||||
---@field interact_pressed boolean One-shot E edge.
|
||||
|
||||
---@class FasetField
|
||||
---@field id? string Stable field ID; must match the enclosing map key.
|
||||
---@field name? string Inspector label.
|
||||
---@field type 'number'|'float'|'integer'|'int'|'boolean'|'bool'|'string'|'asset_ref'|'entity_ref'|'vec2'|'vec3'|'vec4'|'color'|'array'|'object'|'any'
|
||||
---@field default any
|
||||
---@field min? number
|
||||
---@field max? number
|
||||
---@field enum? any[]
|
||||
---@field units? string
|
||||
|
||||
---@class FasetComponent
|
||||
---@field id string Persistent component ID.
|
||||
---@field type string Stable component TypeId.
|
||||
---@field version integer
|
||||
---@field fields table<string, any>
|
||||
|
||||
---@class FasetEntityRecord
|
||||
---@field id string Persistent scene ID.
|
||||
---@field name? string
|
||||
---@field parent? string
|
||||
---@field components FasetComponent[]
|
||||
|
||||
---@class FasetEntity
|
||||
local Entity = {}
|
||||
|
||||
---Checks session and generation. Retained handles can become invalid.
|
||||
---@return boolean
|
||||
function Entity:valid() end
|
||||
|
||||
---Returns a copy of the simulation transform.
|
||||
---@return FasetTransform
|
||||
function Entity:transform() end
|
||||
|
||||
---Returns a copy of the presentation transform.
|
||||
---@return FasetTransform
|
||||
function Entity:presentation() end
|
||||
|
||||
---Returns a copy of component configuration, not live physics state.
|
||||
---@param component_type string
|
||||
---@return table<string, any>
|
||||
function Entity:fields(component_type) end
|
||||
|
||||
---@return FasetVec3 velocity Linear velocity in metres per second. Requires a rigid body.
|
||||
function Entity:velocity() end
|
||||
|
||||
---@return boolean grounded Requires a rigid body; uses completed physics contacts.
|
||||
function Entity:is_grounded() end
|
||||
|
||||
---Sets a non-physical object's simulation transform.
|
||||
---@param transform FasetTransform
|
||||
function Entity:set_transform(transform) end
|
||||
|
||||
---Writes display-only pose. Permitted only during late_update.
|
||||
---@param transform FasetTransform
|
||||
function Entity:set_presentation(transform) end
|
||||
|
||||
---Discontinuous pose change, including physical bodies; preserves velocity.
|
||||
---@param transform FasetTransform
|
||||
function Entity:teleport(transform) end
|
||||
|
||||
---@param velocity FasetVec3
|
||||
function Entity:set_velocity(velocity) end
|
||||
|
||||
---@param impulse FasetVec3
|
||||
function Entity:apply_impulse(impulse) end
|
||||
|
||||
---Queues destruction at the next fixed-tick barrier.
|
||||
function Entity:destroy() end
|
||||
|
||||
---Queues a full component record at the next fixed-tick barrier.
|
||||
---@param component FasetComponent
|
||||
function Entity:add_component(component) end
|
||||
|
||||
---Queues removal at the next fixed-tick barrier.
|
||||
---@param component_type string
|
||||
function Entity:remove_component(component_type) end
|
||||
|
||||
---@class FasetCollision
|
||||
---@field first FasetEntity
|
||||
---@field second FasetEntity
|
||||
---@field other FasetEntity The entity opposite this behavior's owner.
|
||||
---@field began boolean True for contact begin; false for contact end.
|
||||
|
||||
---@class FasetBehaviorDefinition
|
||||
---@field id string Stable custom component TypeId (faset.* is reserved).
|
||||
---@field version? integer Positive schema version; defaults to 1.
|
||||
---@field name? string Inspector label.
|
||||
---@field fields table<string, FasetField>
|
||||
---@field migrations? table[] Declarative authoring migrations, not Lua callbacks.
|
||||
|
||||
---@class FasetBehavior: FasetInstance
|
||||
---@field on_start? fun(self: FasetInstance)
|
||||
---@field fixed_update? fun(self: FasetInstance, delta: number)
|
||||
---@field update? fun(self: FasetInstance, delta: number)
|
||||
---@field late_update? fun(self: FasetInstance, delta: number)
|
||||
---@field on_destroy? fun(self: FasetInstance)
|
||||
---@field on_collision? fun(self: FasetInstance, event: FasetCollision)
|
||||
|
||||
---@class FasetInstance
|
||||
---@field entity FasetEntity Opaque, generation-checked runtime handle.
|
||||
---@field fields table<string, any> Per-instance configuration: defaults plus scene overrides.
|
||||
---@field state table<string, any> Private mutable state, reset on restart/reload.
|
||||
|
||||
faset = {}
|
||||
|
||||
---@type userdata Explicit JSON null. Unlike nil, retains a key in a JSON object.
|
||||
faset.null = nil
|
||||
|
||||
---Declares one behavior in an entry script. Return this table from the file.
|
||||
---@param definition FasetBehaviorDefinition
|
||||
---@return FasetBehavior
|
||||
function faset.behavior(definition) end
|
||||
|
||||
---@return FasetInput
|
||||
function faset.input() end
|
||||
|
||||
---@param persistent_id string
|
||||
---@return FasetEntity? entity Nil if the scene ID is not present.
|
||||
function faset.find(persistent_id) end
|
||||
|
||||
---Writes a bounded message to Player logs / Editor Console.
|
||||
---@param ... any
|
||||
function faset.log(...) end
|
||||
|
||||
---Queues an entity at the next fixed tick. Does not return a handle.
|
||||
---@param entity FasetEntityRecord
|
||||
function faset.spawn(entity) end
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"runtime.version": "Lua 5.4",
|
||||
"runtime.path": ["Scripts/?.lua", "Scripts/?/init.lua"],
|
||||
"workspace.library": [".faset/lua"],
|
||||
"workspace.checkThirdParty": false,
|
||||
"diagnostics.globals": ["faset"]
|
||||
}
|
||||
Reference in New Issue
Block a user