diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a39030..adfbe59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b329c6..5707350 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md index e3cb964..287cb81 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/player_main.cpp b/apps/player_main.cpp index 5381df5..ef2af23 100644 --- a/apps/player_main.cpp +++ b/apps/player_main.cpp @@ -7,8 +7,13 @@ #include #include #include +#include +#if defined(FASET_HAS_LUA) +#include +#endif #include #include +#include #include #include #include @@ -177,28 +182,35 @@ 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 options; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; if (arg == "--help") { - std::cout << "faset_player [--scene PATH] [--assets CACHE] [--frames N] " - "[--headless] [--capture PATH.ppm] [--validate] [--control PATH] " - "[--profile PATH.json] [--debug-physics]\n" - "No --scene: open scene.fscene beside the executable. CACHE contains " - "assets//.\n" - "Headless uses offscreen Vulkan; --frames uses the configured fixed " - "simulation delta.\n" - "--validate checks scene/resources on CPU without gameplay callbacks " - "or Vulkan initialization.\n" - "--control is an optional editor mailbox for pause/resume/step/stop, " - "without world queries.\n" - "--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, " - "N single-step, F3 physics boxes, Escape quit.\n"; + std::cout + << "faset_player [--scene PATH] [--assets CACHE] [--frames N] " + "[--headless] [--capture PATH.ppm] [--validate] [--control PATH] " + "[--profile PATH.json] [--debug-physics] [--project ROOT] " + "[--watch-lua]\n" + "No --scene: open scene.fscene beside the executable. CACHE contains " + "assets//.\n" + "Headless uses offscreen Vulkan; --frames uses the configured fixed " + "simulation delta.\n" + "--validate checks scene/resources on CPU without gameplay callbacks " + "or Vulkan initialization.\n" + "--control is an optional editor mailbox for pause/resume/step/stop, " + "without world queries.\n" + "--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, " + "N single-step, F3 physics boxes, Escape quit.\n"; return 0; } if (!options.insert(arg).second) @@ -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 lua; + if (luaProject.enabled()) { + lua = std::make_unique(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(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 held; bool stop = false; std::uint64_t frames = 0; - std::size_t logCursor = 0; std::set 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 candidateLua; + if (candidateProject.enabled()) { + candidateLua = std::make_unique(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(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(); 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(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(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"); diff --git a/apps/schema_exporter_main.cpp b/apps/schema_exporter_main.cpp index f543dc4..3e9a678 100644 --- a/apps/schema_exporter_main.cpp +++ b/apps/schema_exporter_main.cpp @@ -1,27 +1,51 @@ #include "Gameplay.hpp" #include +#include +#include +#if defined(FASET_HAS_LUA) +#include +#endif #include #include 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'; diff --git a/cmake/BuildService.cmake b/cmake/BuildService.cmake index a7d4102..7d559d3 100644 --- a/cmake/BuildService.cmake +++ b/cmake/BuildService.cmake @@ -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) diff --git a/cmake/Lua.cmake b/cmake/Lua.cmake new file mode 100644 index 0000000..e205c65 --- /dev/null +++ b/cmake/Lua.cmake @@ -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() diff --git a/cmake/Player.cmake b/cmake/Player.cmake index 760a03c..98dd8cd 100644 --- a/cmake/Player.cmake +++ b/cmake/Player.cmake @@ -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 $) + if(TARGET faset_player) + list(APPEND FASET_LUA_CLI_TEST_ARGS --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 $) + set_tests_properties(lua_player_reload PROPERTIES LABELS "gpu" TIMEOUT 90) + endif() +endif() diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 51cbdb5..f20e815 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -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) diff --git a/dependencies.lock.json b/dependencies.lock.json index 39aaf6f..60b1a3a 100644 --- a/dependencies.lock.json +++ b/dependencies.lock.json @@ -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", diff --git a/docs/licenses/Lua.txt b/docs/licenses/Lua.txt new file mode 100644 index 0000000..f3ca1b9 --- /dev/null +++ b/docs/licenses/Lua.txt @@ -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. diff --git a/docs/manual/getting-started/build.md b/docs/manual/getting-started/build.md index a133715..614f5c2 100644 --- a/docs/manual/getting-started/build.md +++ b/docs/manual/getting-started/build.md @@ -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 diff --git a/docs/manual/index.md b/docs/manual/index.md index d5d88aa..98679d3 100644 --- a/docs/manual/index.md +++ b/docs/manual/index.md @@ -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 diff --git a/docs/manual/scripting/index.md b/docs/manual/scripting/index.md index ef86519..b50d3f3 100644 --- a/docs/manual/scripting/index.md +++ b/docs/manual/scripting/index.md @@ -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 diff --git a/docs/manual/scripting/lua.md b/docs/manual/scripting/lua.md new file mode 100644 index 0000000..8d2aa1b --- /dev/null +++ b/docs/manual/scripting/lua.md @@ -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. diff --git a/docs/validation/lua-module.md b/docs/validation/lua-module.md new file mode 100644 index 0000000..683f682 --- /dev/null +++ b/docs/validation/lua-module.md @@ -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 --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. diff --git a/examples/lua/README.md b/examples/lua/README.md new file mode 100644 index 0000000..d16b5dd --- /dev/null +++ b/examples/lua/README.md @@ -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. diff --git a/examples/lua/Scenes/main.scene.json b/examples/lua/Scenes/main.scene.json new file mode 100644 index 0000000..69eea2b --- /dev/null +++ b/examples/lua/Scenes/main.scene.json @@ -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": [] +} diff --git a/examples/lua/Scripts/beacon.lua b/examples/lua/Scripts/beacon.lua new file mode 100644 index 0000000..4f66f33 --- /dev/null +++ b/examples/lua/Scripts/beacon.lua @@ -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 diff --git a/examples/lua/Scripts/player.lua b/examples/lua/Scripts/player.lua new file mode 100644 index 0000000..614fb04 --- /dev/null +++ b/examples/lua/Scripts/player.lua @@ -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 diff --git a/examples/lua/Scripts/util/motion.lua b/examples/lua/Scripts/util/motion.lua new file mode 100644 index 0000000..614d245 --- /dev/null +++ b/examples/lua/Scripts/util/motion.lua @@ -0,0 +1,7 @@ +local motion = {} + +function motion.bob(time, frequency, amplitude) + return math.sin(time * frequency * 2 * math.pi) * amplitude +end + +return motion diff --git a/examples/lua/project.faset.json b/examples/lua/project.faset.json new file mode 100644 index 0000000..2d069f7 --- /dev/null +++ b/examples/lua/project.faset.json @@ -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"] + } + } +} diff --git a/include/faset/core/process.hpp b/include/faset/core/process.hpp index 90f2e43..add3d42 100644 --- a/include/faset/core/process.hpp +++ b/include/faset/core/process.hpp @@ -36,4 +36,9 @@ class Process { std::unique_ptr 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& arguments, + const std::filesystem::path& working_directory = {}); } // namespace faset diff --git a/include/faset/scripting/LuaModule.hpp b/include/faset/scripting/LuaModule.hpp new file mode 100644 index 0000000..8157750 --- /dev/null +++ b/include/faset/scripting/LuaModule.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +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 takeLogs(); + + private: + struct Impl; + std::shared_ptr impl_; +}; + +} // namespace faset::scripting diff --git a/include/faset/scripting/project.hpp b/include/faset/scripting/project.hpp new file mode 100644 index 0000000..ed920eb --- /dev/null +++ b/include/faset/scripting/project.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +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 scripts; + std::map 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 diff --git a/mkdocs.yml b/mkdocs.yml index 629fb85..c0ef3c3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/src/core/process.cpp b/src/core/process.cpp index f459c6e..6c06b5c 100644 --- a/src/core/process.cpp +++ b/src/core/process.cpp @@ -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& 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 argv; + for (const auto& argument : arguments) + argv.push_back(const_cast(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{}; diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 5975ba5..6723b65 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -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 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 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 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()); + 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())); + path_from_utf8(built.at("build_directory").get()), + 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")}, diff --git a/src/editor/editor_ui.cpp b/src/editor/editor_ui.cpp index 7186cb4..bb2251e 100644 --- a/src/editor/editor_ui.cpp +++ b/src/editor/editor_ui.cpp @@ -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 keep; for (const auto& file : files) { const auto path = file.get(); @@ -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); diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 2ce25bc..d7fa696 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -3,6 +3,7 @@ #include #include #include +#include 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 files; if (std::filesystem::exists(directory)) for (const auto& file : std::filesystem::recursive_directory_iterator(directory)) - if (file.is_regular_file()) + if (file.is_regular_file() && (!lua.enabled() || file.path().extension() != ".lua")) files.push_back(file.path()); std::sort(files.begin(), files.end()); std::string contents; for (const auto& file : files) contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" + sha256_file(file) + "\n"; + if (lua.enabled()) + contents += "lua:" + lua.fingerprint + "\n"; return sha256(contents); } Json Session::schema_status() const { - return {{"loaded", schema_loaded_}, - {"stale", !schema_loaded_ || schema_source_signature_ != source_signature() || - !schema_error_.empty()}, - {"error", schema_error_}}; + try { + const auto signature = source_signature(); + return {{"loaded", schema_loaded_}, + {"stale", !schema_loaded_ || schema_source_signature_ != signature || + !schema_error_.empty()}, + {"error", schema_error_}}; + } catch (const std::exception& error) { + return {{"loaded", schema_loaded_}, {"stale", true}, {"error", error.what()}}; + } } Json Session::jobs() const { Json list = Json::array(); @@ -185,9 +194,14 @@ void Session::launch_player(Json scene, const std::filesystem::path& executable) options.arguments = { path_to_utf8(executable), "--scene", path_to_utf8(snapshot), "--assets", path_to_utf8(assets_.cache_root()), "--control", path_to_utf8(control_path_)}; + if (scripting::loadLuaProject(config_.project_root).enabled()) { + options.arguments.insert(options.arguments.end(), + {"--project", path_to_utf8(config_.project_root), "--watch-lua"}); + } options.working_directory = config_.project_root; player_ = std::make_unique(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())); + 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 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(); + 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()); + 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.", diff --git a/src/scripting/LuaModule.cpp b/src/scripting/LuaModule.cpp new file mode 100644 index 0000000..bb09c96 --- /dev/null +++ b/src/scripting/LuaModule.cpp @@ -0,0 +1,1077 @@ +#include +#include + +extern "C" { +#include +#include +#include +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::scripting { +namespace { +using Json = nlohmann::json; +constexpr int hookInterval = 100; +constexpr std::size_t maxJsonNodes = 100'000; +constexpr std::size_t maxJsonBytes = 16 * 1024 * 1024; +constexpr std::size_t maxStructuralCommands = 1024; +char nullToken; + +void require(bool condition, const std::string& message) { + if (!condition) + throw std::invalid_argument(message); +} + +std::string stringArgument(lua_State* state, int index) { + require(lua_type(state, index) == LUA_TSTRING, "expected a string"); + std::size_t length{}; + const char* value = lua_tolstring(state, index, &length); + return std::string(value, length); +} + +// Lua strings/tables may be shared many times. JSON owns each occurrence, so +// the VM allocation quota alone cannot bound expansion into native memory. +struct JsonBudget { + std::size_t nodes{}, bytes{}; + void charge(std::size_t amount) { + require(amount <= maxJsonBytes - bytes, "Lua JSON byte budget exhausted (16 MiB)"); + bytes += amount; + } +}; + +// Reads use raw Lua operations only: no metamethod can interrupt C++ object lifetimes. +Json readJson(lua_State* state, int index, std::set& ancestors, JsonBudget& budget, + unsigned depth = 0) { + require(lua_checkstack(state, 4) != 0, "Lua stack budget exhausted"); + require(++budget.nodes <= maxJsonNodes && depth < 32, + "Lua JSON value is too large or deeply nested"); + budget.charge(64); // Conservative scalar/container bookkeeping charge. + index = lua_absindex(state, index); + switch (lua_type(state, index)) { + case LUA_TNIL: + return nullptr; + case LUA_TBOOLEAN: + return lua_toboolean(state, index) != 0; + case LUA_TNUMBER: + if (lua_isinteger(state, index)) + return lua_tointeger(state, index); + require(std::isfinite(lua_tonumber(state, index)), "JSON numbers must be finite"); + return lua_tonumber(state, index); + case LUA_TSTRING: { + std::size_t length{}; + lua_tolstring(state, index, &length); + budget.charge(length); + return stringArgument(state, index); + } + case LUA_TLIGHTUSERDATA: + require(lua_touserdata(state, index) == &nullToken, "unsupported JSON userdata"); + return nullptr; + case LUA_TTABLE: { + const auto* identity = lua_topointer(state, index); + require(ancestors.insert(identity).second, "cyclic table is not a JSON value"); + Json object = Json::object(); + std::map entries; + lua_pushnil(state); + while (lua_next(state, index) != 0) { + if (lua_type(state, -2) == LUA_TSTRING) { + std::size_t length{}; + lua_tolstring(state, -2, &length); + budget.charge(length); + const auto key = stringArgument(state, -2); + object[key] = readJson(state, -1, ancestors, budget, depth + 1); + } else { + require(lua_isinteger(state, -2) && lua_tointeger(state, -2) > 0, + "JSON table keys must be strings or positive array indices"); + entries.emplace(lua_tointeger(state, -2), + readJson(state, -1, ancestors, budget, depth + 1)); + } + lua_pop(state, 1); + } + ancestors.erase(identity); + require(object.empty() || entries.empty(), "JSON table cannot mix string and array keys"); + if (entries.empty()) + return object; + Json array = Json::array(); + for (auto& [key, value] : entries) { + require(key == static_cast(array.size()) + 1, + "JSON array indices must be contiguous"); + array.push_back(std::move(value)); + } + return array; + } + default: + throw std::invalid_argument("functions, threads and entity handles are not JSON values"); + } +} + +Json readJson(lua_State* state, int index, JsonBudget& budget) { + require(lua_checkstack(state, 100) != 0, "Lua stack budget exhausted"); + std::set ancestors; + return readJson(state, index, ancestors, budget); +} + +Json readJson(lua_State* state, int index) { + JsonBudget budget; + return readJson(state, index, budget); +} + +void validateField(const Json& value, const Json& descriptor) { + const auto kind = descriptor.value("type", std::string("any")); + bool valid{}; + if (kind == "number" || kind == "float") + valid = value.is_number() && std::isfinite(value.get()); + else if (kind == "integer" || kind == "int") + valid = value.is_number_integer(); + else if (kind == "boolean" || kind == "bool") + valid = value.is_boolean(); + else if (kind == "string" || kind == "asset_ref" || kind == "entity_ref") + valid = value.is_string(); + else if (kind == "vec2" || kind == "vec3" || kind == "vec4" || kind == "color") { + const auto size = kind == "vec2" ? 2u : (kind == "vec3" ? 3u : 4u); + valid = value.is_array() && value.size() == size; + if (valid) + for (const auto& entry : value) + valid = valid && entry.is_number() && std::isfinite(entry.get()); + } else if (kind == "array") + valid = value.is_array(); + else if (kind == "object") + valid = value.is_object(); + else { + require(kind == "any", "unsupported schema field type: " + kind); + valid = true; + } + require(valid, "invalid field " + descriptor.value("id", std::string("?")) + " (expected " + + kind + ")"); + if (value.is_number()) { + if (descriptor.contains("min")) + require(value.get() >= descriptor.at("min").get(), + "field " + descriptor.value("id", std::string("?")) + " is below its minimum"); + if (descriptor.contains("max")) + require(value.get() <= descriptor.at("max").get(), + "field " + descriptor.value("id", std::string("?")) + " exceeds its maximum"); + } + if (descriptor.contains("enum")) { + bool found{}; + for (const auto& option : descriptor.at("enum")) + found = found || option == value; + require(found, "field " + descriptor.value("id", std::string("?")) + + " is not an allowed enum choice"); + } +} + +void normalizeSchema(Json& value) { + require(value.is_object() && value.contains("id") && value.at("id").is_string(), + "Lua behavior requires a string id"); + const auto id = value.at("id").get(); + require(!id.empty() && !runtime::is_builtin_component(id), "invalid or builtin behavior id"); + if (!value.contains("version")) + value["version"] = 1; + require(value.at("version").is_number_integer() && value.at("version") > 0 && + value.at("version") <= std::numeric_limits::max(), + "schema version must be a positive integer"); + if (!value.contains("name")) + value["name"] = id; + require(value.at("name").is_string(), "schema name must be a string"); + if (!value.contains("fields")) + value["fields"] = Json::object(); + require(value.at("fields").is_object(), "schema fields must be an object"); + for (auto& [key, field] : value["fields"].items()) { + require(!key.empty() && field.is_object() && field.contains("default"), + "each field requires an id and typed default"); + require(field.value("id", key) == key, "field id must match its map key"); + field["id"] = key; + if (field.value("type", std::string{}) == "array" && field["default"].is_object() && + field["default"].empty()) + field["default"] = Json::array(); + for (const auto* limit : {"min", "max"}) + if (field.contains(limit)) + require(field.at(limit).is_number() && std::isfinite(field.at(limit).get()), + "field limits must be finite numbers"); + if (field.contains("min") && field.contains("max")) + require(field.at("min") <= field.at("max"), "field minimum exceeds maximum"); + if (field.contains("enum")) { + if (field["enum"].is_object() && field["enum"].empty()) + field["enum"] = Json::array(); + require(field["enum"].is_array(), "field enum must be an array"); + } + validateField(field.at("default"), field); + } + if (value.contains("migrations")) { + if (value["migrations"].is_object() && value["migrations"].empty()) + value["migrations"] = Json::array(); + require(value["migrations"].is_array(), "migrations must be an array"); + std::set versions; + for (const auto& step : value["migrations"]) { + require(step.is_object() && step.contains("from_version") && + step.at("from_version").is_number_integer() && + step.at("from_version") > 0 && step.at("from_version") < value["version"] && + step.contains("fields") && step.at("fields").is_object(), + "invalid migration step"); + require(versions.insert(step.at("from_version").get()).second, + "duplicate migration version"); + for (const auto& [key, unused] : step.items()) + require(key == "from_version" || key == "fields", "unsupported migration property"); + for (const auto& [field, rules] : step.at("fields").items()) { + require(!field.empty() && rules.is_object(), "invalid migration field rule"); + for (const auto& [operation, argument] : rules.items()) { + require(operation == "default" || operation == "scale" || + operation == "require_manual", + "unsupported migration operation"); + if (operation == "scale") + require(argument.is_number() && std::isfinite(argument.get()), + "migration scale must be finite"); + if (operation == "require_manual") + require(argument.is_boolean(), "require_manual must be boolean"); + } + } + } + } +} + +// An immutable, precomputed marshaling tree. Lua allocations never occur with +// owning C++ temporaries or iterators on the C stack (Lua errors use longjmp). +struct Value { + enum class Kind { Null, Boolean, Integer, Number, String, Array, Object } kind{Kind::Null}; + bool boolean{}; + lua_Integer integer{}; + double number{}; + std::string string; + std::vector keys; + std::vector children; + Value() = default; + explicit Value(const Json& json) { + if (json.is_boolean()) { + kind = Kind::Boolean; + boolean = json.get(); + } else if (json.is_number_integer()) { + if (json.is_number_unsigned()) + require(json.get() <= static_cast(LUA_MAXINTEGER), + "integer exceeds Lua's exact range"); + kind = Kind::Integer; + integer = json.get(); + } else if (json.is_number()) { + kind = Kind::Number; + number = json.get(); + } else if (json.is_string()) { + kind = Kind::String; + string = json.get(); + } else if (json.is_array()) { + kind = Kind::Array; + for (const auto& child : json) + children.emplace_back(child); + } else if (json.is_object()) { + kind = Kind::Object; + for (const auto& [key, child] : json.items()) { + keys.push_back(key); + children.emplace_back(child); + } + } + } +}; + +void pushValue(lua_State* state, const Value& value) { + if (!lua_checkstack(state, 4)) + luaL_error(state, "Lua stack budget exhausted"); + switch (value.kind) { + case Value::Kind::Null: + lua_pushlightuserdata(state, &nullToken); + break; + case Value::Kind::Boolean: + lua_pushboolean(state, value.boolean); + break; + case Value::Kind::Integer: + lua_pushinteger(state, value.integer); + break; + case Value::Kind::Number: + lua_pushnumber(state, value.number); + break; + case Value::Kind::String: + lua_pushlstring(state, value.string.data(), value.string.size()); + break; + case Value::Kind::Array: + case Value::Kind::Object: + lua_createtable( + state, value.kind == Value::Kind::Array ? static_cast(value.children.size()) : 0, + value.kind == Value::Kind::Object ? static_cast(value.children.size()) : 0); + for (std::size_t i = 0; i < value.children.size(); ++i) { + if (value.kind == Value::Kind::Object) + lua_pushlstring(state, value.keys[i].data(), value.keys[i].size()); + pushValue(state, value.children[i]); + if (value.kind == Value::Kind::Array) + lua_rawseti(state, -2, static_cast(i + 1)); + else + lua_rawset(state, -3); + } + break; + } +} + +void pushVector(lua_State* state, const runtime::Vec3& vector) { + lua_createtable(state, 0, 3); + lua_pushnumber(state, vector[0]); + lua_setfield(state, -2, "x"); + lua_pushnumber(state, vector[1]); + lua_setfield(state, -2, "y"); + lua_pushnumber(state, vector[2]); + lua_setfield(state, -2, "z"); +} +void pushTransform(lua_State* state, const runtime::Transform& transform) { + lua_createtable(state, 0, 3); + pushVector(state, transform.position); + lua_setfield(state, -2, "position"); + pushVector(state, transform.rotation); + lua_setfield(state, -2, "rotation"); + pushVector(state, transform.scale); + lua_setfield(state, -2, "scale"); +} +runtime::Vec3 readVector(lua_State* state, int index) { + const auto value = readJson(state, index); + require(value.is_object(), "vector must be a table with x, y, z"); + runtime::Vec3 result{}; + unsigned i{}; + for (const char* axis : {"x", "y", "z"}) { + require(value.contains(axis) && value.at(axis).is_number(), "vector requires x, y, z"); + const auto number = value.at(axis).get(); + require(std::isfinite(number) && std::abs(number) <= std::numeric_limits::max(), + "vector component must be finite and fit float"); + result[i++] = static_cast(number); + } + return result; +} +runtime::Transform readTransform(lua_State* state, int index) { + // This path makes no Lua allocations, even with C++ JSON temporaries alive. + const auto value = readJson(state, index); + require(value.is_object(), "transform must be an object"); + runtime::Transform result; + auto read = [&](const char* key, runtime::Vec3& target) { + require(value.contains(key) && value.at(key).is_object(), + std::string("transform requires ") + key); + unsigned i{}; + for (const char* axis : {"x", "y", "z"}) { + const auto& vector = value.at(key); + require(vector.contains(axis) && vector.at(axis).is_number(), + "vector requires x, y, z"); + const auto number = vector.at(axis).get(); + require(std::isfinite(number) && std::abs(number) <= std::numeric_limits::max(), + "transform component must be finite and fit float"); + target[i++] = static_cast(number); + } + }; + read("position", result.position); + read("rotation", result.rotation); + read("scale", result.scale); + return result; +} +} // namespace + +struct LuaModule::Impl { + struct Memory { + std::size_t used{}, limit{}; + } memory; + struct alignas(std::max_align_t) Allocation { + std::size_t bytes; + }; + struct Definition { + std::string path, id; + Json schema; + int reference{LUA_NOREF}; + }; + struct Instance { + int reference{LUA_NOREF}; + bool disabled{}; + Value fields; + }; + using Key = std::tuple; + struct Invocation { + Definition* definition{}; + Instance* instance{}; + runtime::EntityHandle entity; + const char* callback{}; + double delta{}; + const runtime::CollisionEvent* collision{}; + bool destroy{}; + }; + LuaProject project; + LuaLimits limits; + lua_State* state{}; + std::vector definitions; + std::map instances; + Json schemas = Json::array(); + std::vector logs; + runtime::Runtime* activeRuntime{}; + Invocation* activeInvocation{}; + const char* loadingPath{}; + std::map modules; + std::set loadingModules; + int entityMetatable{LUA_NOREF}, behaviorSet{LUA_NOREF}; + std::size_t instructionsLeft{}; + std::size_t structuralCommands{}; + JsonBudget metadataBudget, structuralBudget; + bool budgetExceeded{}; + Value output; + + static void* allocate(void* user, void* pointer, std::size_t, std::size_t size) { + auto& memory = *static_cast(user); + auto* allocation = pointer ? static_cast(pointer) - 1 : nullptr; + const auto oldSize = allocation ? allocation->bytes : 0; + if (!size) { + std::free(allocation); + memory.used -= oldSize; + return nullptr; + } + if (size > std::numeric_limits::max() - sizeof(Allocation)) + return nullptr; + size += sizeof(Allocation); + if (size > oldSize && size - oldSize > memory.limit - memory.used) + return nullptr; + auto* next = static_cast(std::realloc(allocation, size)); + if (!next) { + // Lua requires shrinking allocations to succeed. Keeping the larger + // block is safe; its actual size remains charged to the VM budget. + return size <= oldSize ? pointer : nullptr; + } + next->bytes = size; + memory.used = memory.used - oldSize + size; + return next + 1; + } + static Impl& get(lua_State* state) { + return **static_cast(lua_getextraspace(state)); + } + static void hook(lua_State* state, lua_Debug*) { + auto& self = get(state); + if (self.instructionsLeft <= hookInterval) { + self.budgetExceeded = true; + luaL_error(state, "Lua instruction budget exhausted"); + } + self.instructionsLeft -= hookInterval; + } + // Exception boundaries never call lua_error until C++ catch objects have died. + template static int guarded(lua_State* state) { + char error[2048]{}; + try { + return Function(state); + } catch (const std::exception& exception) { + std::snprintf(error, sizeof(error), "%s", exception.what()); + } catch (...) { + std::snprintf(error, sizeof(error), "unknown native Lua API error"); + } + lua_pushstring(state, error); + return lua_error(state); + } + static int traceback(lua_State* state) { + const char* message = + lua_type(state, 1) == LUA_TSTRING ? lua_tostring(state, 1) : "non-string Lua error"; + luaL_traceback(state, state, message, 1); + return 1; + } + int protectedCall(lua_CFunction function) { + lua_settop(state, 0); + // Zero-upvalue C functions do not allocate. The following pcall protects + // bootstrapping, argument marshaling and the Lua function itself. + lua_pushcfunction(state, traceback); + lua_pushcfunction(state, function); + instructionsLeft = limits.instructions; + structuralCommands = 0; + structuralBudget = {}; + budgetExceeded = false; + lua_sethook(state, hook, LUA_MASKCOUNT, hookInterval); + const int status = lua_pcall(state, 0, 0, 1); + lua_sethook(state, nullptr, 0, 0); + return status; + } + std::string errorText(int status) const { + if (status == LUA_ERRMEM) + return "Lua memory budget exhausted"; + if (lua_type(state, -1) == LUA_TSTRING) + return lua_tostring(state, -1); + return "Lua execution failed"; + } + explicit Impl(const LuaProject& value, LuaLimits configured) + : project(value), limits(configured) { + require(limits.memoryBytes > 0 && limits.instructions > 0, "Lua budgets must be positive"); + memory.limit = limits.memoryBytes; + state = lua_newstate(allocate, &memory); + if (!state) + throw std::runtime_error("Lua memory budget exhausted during initialization"); + *static_cast(lua_getextraspace(state)) = this; + try { + int status = protectedCall(guarded); + if (status != LUA_OK) + throw std::runtime_error(errorText(status)); + std::set ids; + definitions.reserve(project.scripts.size()); + for (const auto& path : project.scripts) { + require(project.sources.contains(path), "missing Lua entry source: " + path); + loadingPath = path.c_str(); + status = protectedCall(guarded); + loadingModules.clear(); + if (status != LUA_OK) + throw std::runtime_error(path + ": " + errorText(status)); + require(ids.insert(definitions.back().id).second, + "duplicate Lua behavior id: " + definitions.back().id); + schemas.push_back(definitions.back().schema); + } + loadingPath = nullptr; + lua_settop(state, 0); + } catch (...) { + lua_close(state); + state = nullptr; + throw; + } + } + ~Impl() { + if (state) + lua_close(state); + } + + static int bootstrap(lua_State* state) { + auto& self = get(state); + luaL_requiref(state, "_G", luaopen_base, 1); + lua_pop(state, 1); + luaL_requiref(state, LUA_MATHLIBNAME, luaopen_math, 1); + lua_pop(state, 1); + luaL_requiref(state, LUA_STRLIBNAME, luaopen_string, 1); + lua_pop(state, 1); + luaL_requiref(state, LUA_TABLIBNAME, luaopen_table, 1); + lua_pop(state, 1); + luaL_requiref(state, LUA_UTF8LIBNAME, luaopen_utf8, 1); + lua_pop(state, 1); + for (const char* name : + {"dofile", "loadfile", "load", "collectgarbage", "pcall", "xpcall", "setmetatable"}) { + lua_pushnil(state); + lua_setglobal(state, name); + } + // Bytecode serialization has no useful role in an immutable text-only project. + lua_getglobal(state, "string"); + lua_pushnil(state); + lua_setfield(state, -2, "dump"); + lua_pop(state, 1); + // Hide the string metatable as well: otherwise scripts could install + // __close handlers on strings and execute code during error unwinding. + lua_pushliteral(state, ""); + if (lua_getmetatable(state, -1)) { + lua_pushliteral(state, "string"); + lua_setfield(state, -2, "__metatable"); + lua_pop(state, 1); + } + lua_pop(state, 1); + lua_newtable(state); + self.behaviorSet = luaL_ref(state, LUA_REGISTRYINDEX); + lua_newtable(state); + static const luaL_Reg entityFunctions[] = { + {"valid", guarded}, + {"transform", guarded}, + {"presentation", guarded}, + {"fields", guarded}, + {"velocity", guarded}, + {"is_grounded", guarded}, + {"set_transform", guarded}, + {"set_presentation", guarded}, + {"teleport", guarded}, + {"set_velocity", guarded}, + {"apply_impulse", guarded}, + {"destroy", guarded}, + {"add_component", guarded}, + {"remove_component", guarded}, + {"__eq", guarded}, + {nullptr, nullptr}}; + luaL_setfuncs(state, entityFunctions, 0); + lua_pushvalue(state, -1); + lua_setfield(state, -2, "__index"); + lua_pushliteral(state, "faset.Entity"); + lua_setfield(state, -2, "__metatable"); + self.entityMetatable = luaL_ref(state, LUA_REGISTRYINDEX); + lua_newtable(state); + static const luaL_Reg functions[] = { + {"behavior", guarded}, {"find", guarded}, {"input", guarded}, + {"log", guarded}, {"spawn", guarded}, {nullptr, nullptr}}; + luaL_setfuncs(state, functions, 0); + lua_pushlightuserdata(state, &nullToken); + lua_setfield(state, -2, "null"); + lua_setglobal(state, "faset"); + lua_pushcfunction(state, guarded); + lua_setglobal(state, "require"); + lua_pushcfunction(state, guarded); + lua_setglobal(state, "print"); + return 0; + } + static int behavior(lua_State* state) { + require(lua_istable(state, 1), "faset.behavior expects a descriptor table"); + lua_settop(state, 1); + lua_rawgeti(state, LUA_REGISTRYINDEX, get(state).behaviorSet); + lua_pushvalue(state, 1); + lua_pushboolean(state, true); + lua_rawset(state, -3); + lua_pop(state, 1); + return 1; + } + static int loadEntry(lua_State* state) { + auto& self = get(state); + // The map's referenced source survives every Lua allocation/error. + const auto& source = self.project.sources.at(self.loadingPath); + if (luaL_loadbufferx(state, source.data(), source.size(), self.loadingPath, "t") != LUA_OK) + return lua_error(state); + lua_call(state, 0, 1); + require(lua_istable(state, -1), "Lua entry must return a faset.behavior table"); + lua_rawgeti(state, LUA_REGISTRYINDEX, self.behaviorSet); + lua_pushvalue(state, -2); + lua_rawget(state, -2); + const bool declared = lua_toboolean(state, -1); + lua_pop(state, 2); + require(declared, "Lua entry must return a faset.behavior table"); + // Copy only metadata; callbacks are Lua functions and never JSON. + // Field names are pushed BEFORE creating C++ values that own memory. + self.definitions.emplace_back(); + self.definitions.back().path = self.loadingPath; + self.definitions.back().schema = Json::object(); + for (const char* key : {"id", "version", "name", "fields", "migrations"}) { + lua_pushstring(state, key); + lua_rawget(state, -2); + if (!lua_isnil(state, -1)) + self.definitions.back().schema[key] = readJson(state, -1, self.metadataBudget); + lua_pop(state, 1); + } + normalizeSchema(self.definitions.back().schema); + self.definitions.back().id = self.definitions.back().schema.at("id").get(); + for (const char* name : + {"on_start", "fixed_update", "update", "late_update", "on_destroy", "on_collision"}) { + lua_pushstring(state, name); + lua_rawget(state, -2); + require(lua_isnil(state, -1) || lua_isfunction(state, -1), + "behavior callback must be a function"); + lua_pop(state, 1); + } + self.definitions.back().reference = luaL_ref(state, LUA_REGISTRYINDEX); + return 0; + } + static int moduleRequire(lua_State* state) { + auto& self = get(state); + // All allocation-owning temporaries die before loading/calling Lua. + const std::string* path{}; + const std::string* source{}; + int cached = LUA_NOREF; + { + auto name = stringArgument(state, 1); + require(!name.empty() && name.size() <= 512 && name.front() != '.' && + name.back() != '.', + "invalid Lua module name"); + bool dot{}; + for (char& c : name) { + require((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '.', + "invalid Lua module name"); + require(!(dot && c == '.'), "invalid Lua module name"); + dot = c == '.'; + if (dot) + c = '/'; + } + auto found = self.project.sources.find("Scripts/" + name + ".lua"); + if (found == self.project.sources.end()) + found = self.project.sources.find("Scripts/" + name + "/init.lua"); + require(found != self.project.sources.end(), + "Lua module not in project snapshot: " + name); + path = &found->first; + source = &found->second; + if (const auto loaded = self.modules.find(*path); loaded != self.modules.end()) + cached = loaded->second; + else + require(self.loadingModules.insert(*path).second, "cyclic Lua require: " + *path); + } + if (cached != LUA_NOREF) { + lua_rawgeti(state, LUA_REGISTRYINDEX, cached); + return 1; + } + if (luaL_loadbufferx(state, source->data(), source->size(), path->c_str(), "t") != LUA_OK) + return lua_error(state); + lua_call(state, 0, 1); + if (lua_isnil(state, -1)) { + lua_pop(state, 1); + lua_pushboolean(state, true); + } + lua_pushvalue(state, -1); + const int reference = luaL_ref(state, LUA_REGISTRYINDEX); + self.modules.emplace(*path, reference); + self.loadingModules.erase(*path); + return 1; + } + runtime::Runtime& world() { + require(activeRuntime != nullptr, + "runtime API is only available inside behavior callbacks"); + return *activeRuntime; + } + static runtime::EntityHandle entity(lua_State* state, int index = 1) { + require(lua_type(state, index) == LUA_TUSERDATA && + lua_rawlen(state, index) == sizeof(runtime::EntityHandle), + "expected a faset.Entity"); + require(lua_getmetatable(state, index) != 0, "expected a faset.Entity"); + lua_rawgeti(state, LUA_REGISTRYINDEX, get(state).entityMetatable); + const bool valid = lua_rawequal(state, -1, -2); + lua_pop(state, 2); + require(valid, "expected a faset.Entity"); + return *static_cast(lua_touserdata(state, index)); + } + static void pushEntity(lua_State* state, runtime::EntityHandle handle) { + auto* value = + static_cast(lua_newuserdatauv(state, sizeof(handle), 0)); + *value = handle; + lua_rawgeti(state, LUA_REGISTRYINDEX, get(state).entityMetatable); + lua_setmetatable(state, -2); + } + static int find(lua_State* state) { + runtime::EntityHandle handle; + { + const auto id = stringArgument(state, 1); + handle = get(state).world().find(id); + } + if (handle) + pushEntity(state, handle); + else + lua_pushnil(state); + return 1; + } + static int input(lua_State* state) { + const auto input = get(state).world().input(); + lua_createtable(state, 0, 4); + lua_pushnumber(state, input.horizontal); + lua_setfield(state, -2, "horizontal"); + lua_pushnumber(state, input.vertical); + lua_setfield(state, -2, "vertical"); + lua_pushboolean(state, input.jumpPressed); + lua_setfield(state, -2, "jump_pressed"); + lua_pushboolean(state, input.interactPressed); + lua_setfield(state, -2, "interact_pressed"); + return 1; + } + static int log(lua_State* state) { + auto& self = get(state); + if (self.logs.size() >= 256) + return 0; + std::string message; + for (int i = 1; i <= lua_gettop(state) && message.size() < 4096; ++i) { + if (i > 1) + message += '\t'; + switch (lua_type(state, i)) { + case LUA_TSTRING: { + std::size_t length{}; + const char* text = lua_tolstring(state, i, &length); + message.append(text, std::min(length, 4096 - message.size())); + break; + } + case LUA_TNUMBER: { + char buffer[64]; + if (lua_isinteger(state, i)) + std::snprintf(buffer, sizeof(buffer), "%lld", + static_cast(lua_tointeger(state, i))); + else + std::snprintf(buffer, sizeof(buffer), "%.14g", lua_tonumber(state, i)); + message += buffer; + break; + } + case LUA_TBOOLEAN: + message += lua_toboolean(state, i) ? "true" : "false"; + break; + case LUA_TNIL: + message += "nil"; + break; + default: + message += lua_typename(state, lua_type(state, i)); + break; + } + } + if (message.size() > 4096) + message.resize(4096); + self.logs.push_back(std::move(message)); + return 0; + } + void structuralCommand() { + require(++structuralCommands <= maxStructuralCommands, + "Lua structural command budget exhausted (1024 per callback)"); + structuralBudget.charge(64); + } + static int spawn(lua_State* state) { + auto& self = get(state); + auto& world = self.world(); + self.structuralCommand(); + world.spawn(readJson(state, 1, self.structuralBudget)); + return 0; + } + static int entityValid(lua_State* state) { + lua_pushboolean(state, get(state).world().valid(entity(state))); + return 1; + } + static int entityEqual(lua_State* state) { + lua_pushboolean(state, entity(state, 1) == entity(state, 2)); + return 1; + } + static int entityTransform(lua_State* state) { + const auto value = get(state).world().transform(entity(state)); + pushTransform(state, value); + return 1; + } + static int entityPresentation(lua_State* state) { + const auto value = get(state).world().presentation(entity(state)); + pushTransform(state, value); + return 1; + } + static int entityFields(lua_State* state) { + auto& self = get(state); + { + const auto type = stringArgument(state, 2); + self.output = Value(self.world().fields(entity(state), type)); + } + pushValue(state, self.output); + return 1; + } + static int entityVelocity(lua_State* state) { + const auto value = get(state).world().velocity(entity(state)); + pushVector(state, value); + return 1; + } + static int entityGrounded(lua_State* state) { + lua_pushboolean(state, get(state).world().grounded(entity(state))); + return 1; + } + static int entitySetTransform(lua_State* state) { + get(state).world().setTransform(entity(state), readTransform(state, 2)); + return 0; + } + static int entitySetPresentation(lua_State* state) { + get(state).world().setPresentation(entity(state), readTransform(state, 2)); + return 0; + } + static int entityTeleport(lua_State* state) { + get(state).world().teleport(entity(state), readTransform(state, 2)); + return 0; + } + static int entitySetVelocity(lua_State* state) { + get(state).world().setVelocity(entity(state), readVector(state, 2)); + return 0; + } + static int entityImpulse(lua_State* state) { + get(state).world().applyImpulse(entity(state), readVector(state, 2)); + return 0; + } + static int entityDestroy(lua_State* state) { + auto& self = get(state); + auto& world = self.world(); + self.structuralCommand(); + world.destroy(entity(state)); + return 0; + } + static int entityAddComponent(lua_State* state) { + auto& self = get(state); + auto& world = self.world(); + self.structuralCommand(); + world.addComponent(entity(state), readJson(state, 2, self.structuralBudget)); + return 0; + } + static int entityRemoveComponent(lua_State* state) { + auto& self = get(state); + auto& world = self.world(); + self.structuralCommand(); + require(lua_type(state, 2) == LUA_TSTRING, "expected a string"); + std::size_t length{}; + lua_tolstring(state, 2, &length); + self.structuralBudget.charge(length); + world.removeComponent(entity(state), stringArgument(state, 2)); + return 0; + } + static int dispatch(lua_State* state) { + auto& self = get(state); + auto& invocation = *self.activeInvocation; + auto& instance = *invocation.instance; + if (instance.reference == LUA_NOREF) { + lua_createtable(state, 0, 3); + pushEntity(state, invocation.entity); + lua_setfield(state, -2, "entity"); + pushValue(state, instance.fields); + lua_setfield(state, -2, "fields"); + lua_newtable(state); + lua_setfield(state, -2, "state"); + // Instance method lookup delegates to its definition; self data is isolated. + lua_newtable(state); + lua_rawgeti(state, LUA_REGISTRYINDEX, invocation.definition->reference); + lua_setfield(state, -2, "__index"); + lua_pushliteral(state, "faset.BehaviorInstance"); + lua_setfield(state, -2, "__metatable"); + lua_setmetatable(state, -2); + instance.reference = luaL_ref(state, LUA_REGISTRYINDEX); + } + lua_rawgeti(state, LUA_REGISTRYINDEX, invocation.definition->reference); + lua_pushstring(state, invocation.callback); + lua_rawget(state, -2); + if (lua_isnil(state, -1)) + return 0; + require(lua_isfunction(state, -1), "behavior callback was replaced with a non-function"); + lua_rawgeti(state, LUA_REGISTRYINDEX, instance.reference); + int arguments = 1; + if (invocation.collision) { + lua_createtable(state, 0, 4); + lua_pushboolean(state, invocation.collision->began); + lua_setfield(state, -2, "began"); + pushEntity(state, invocation.collision->first); + lua_setfield(state, -2, "first"); + pushEntity(state, invocation.collision->second); + lua_setfield(state, -2, "second"); + pushEntity(state, invocation.collision->first == invocation.entity + ? invocation.collision->second + : invocation.collision->first); + lua_setfield(state, -2, "other"); + ++arguments; + } else if (std::strcmp(invocation.callback, "on_start") != 0 && !invocation.destroy) { + lua_pushnumber(state, invocation.delta); + ++arguments; + } + lua_call(state, arguments, 0); + return 0; + } + static int releaseInstance(lua_State* state) { + auto& instance = *get(state).activeInvocation->instance; + if (instance.reference != LUA_NOREF) { + luaL_unref(state, LUA_REGISTRYINDEX, instance.reference); + instance.reference = LUA_NOREF; + } + return 0; + } + static int releaseFailedInstance(lua_State* state) { + releaseInstance(state); + // A failed instance must not keep its entire self.state reachable and + // exhaust the shared VM for healthy instances. User finalizers cannot + // be installed in this sandbox; collection remains protected anyway. + lua_gc(state, LUA_GCCOLLECT); + return 0; + } + void invoke(std::size_t definitionIndex, runtime::Runtime& world, runtime::EntityHandle handle, + const char* callback, double delta, + const runtime::CollisionEvent* collision = nullptr) { + auto& definition = definitions.at(definitionIndex); + const Key key{handle.session, handle.slot, handle.generation, definitionIndex}; + const bool destroying = std::strcmp(callback, "on_destroy") == 0; + auto found = instances.find(key); + if (found == instances.end()) { + if (destroying) + return; + found = instances.emplace(key, Instance{}).first; + try { + auto fields = world.fields(handle, definition.id); + for (const auto& [name, descriptor] : definition.schema.at("fields").items()) { + if (!fields.contains(name)) + fields[name] = descriptor.at("default"); + validateField(fields.at(name), descriptor); + } + found->second.fields = Value(fields); + } catch (const std::exception& error) { + found->second.disabled = true; + throw std::runtime_error(definition.path + " [" + definition.id + + "]: " + error.what()); + } + } + auto& instance = found->second; + Invocation invocation{&definition, &instance, handle, callback, + delta, collision, destroying}; + activeRuntime = &world; + activeInvocation = &invocation; + struct ActiveCall { + Impl& host; + ~ActiveCall() { + host.activeRuntime = nullptr; + host.activeInvocation = nullptr; + host.loadingModules.clear(); + lua_settop(host.state, 0); + } + } activeCall{*this}; + int status = LUA_OK; + std::string error; + if (!instance.disabled) { + status = protectedCall(guarded); + // Marshaling data is needed only until the Lua instance is created. + // Do not retain duplicate defaults, including after a rejected OOM. + instance.fields = Value{}; + if (status != LUA_OK) { + instance.disabled = true; + error = definition.path + " [" + definition.id + "." + callback + + "]: " + errorText(status); + protectedCall(guarded); + } + } + loadingModules.clear(); + if (destroying) { + // Releasing registry references is itself protected, including after OOM. + protectedCall(guarded); + instances.erase(found); + } + if (status != LUA_OK) + throw std::runtime_error(error); + } +}; + +LuaModule::LuaModule(const LuaProject& project, LuaLimits limits) + : impl_(std::make_shared(project, limits)) {} +LuaModule::~LuaModule() = default; +Json LuaModule::schema() const { + return impl_->schemas; +} +void LuaModule::validateScene(const Json& scene) const { + const Json empty = Json::object(); + for (const auto& entity : scene.at("entities")) { + if (!entity.contains("components")) + continue; + for (const auto& component : entity.at("components")) { + const auto type = component.at("type").get(); + const auto definition = + std::find_if(impl_->definitions.begin(), impl_->definitions.end(), + [&](const auto& entry) { return entry.id == type; }); + if (definition == impl_->definitions.end()) + continue; + try { + const auto& fields = component.contains("fields") ? component.at("fields") : empty; + require(fields.is_object(), "component fields must be an object"); + for (const auto& [name, descriptor] : definition->schema.at("fields").items()) + validateField(fields.contains(name) ? fields.at(name) + : descriptor.at("default"), + descriptor); + } catch (const std::exception& error) { + throw std::runtime_error(definition->path + " [" + type + ", entity " + + entity.value("id", std::string("?")) + + "]: " + error.what()); + } + } + } +} +std::vector LuaModule::takeLogs() { + std::vector result; + result.swap(impl_->logs); + return result; +} +void LuaModule::registerBehaviors(runtime::Runtime& runtime) { + for (std::size_t i = 0; i < impl_->definitions.size(); ++i) { + runtime::Behavior behavior; + auto callback = [host = impl_, i](const char* name) { + return [host, i, name](runtime::Runtime& world, runtime::EntityHandle entity, + double dt) { host->invoke(i, world, entity, name, dt); }; + }; + behavior.onStart = callback("on_start"); + behavior.fixedUpdate = callback("fixed_update"); + behavior.update = callback("update"); + behavior.lateUpdate = callback("late_update"); + behavior.onDestroy = callback("on_destroy"); + behavior.onCollision = [host = impl_, i](runtime::Runtime& world, + runtime::EntityHandle entity, + const runtime::CollisionEvent& event) { + host->invoke(i, world, entity, "on_collision", 0, &event); + }; + runtime.registerBehavior(impl_->definitions[i].id, std::move(behavior)); + } +} +} // namespace faset::scripting diff --git a/src/scripting/empty_gameplay/Gameplay.cpp b/src/scripting/empty_gameplay/Gameplay.cpp new file mode 100644 index 0000000..0f4be2e --- /dev/null +++ b/src/scripting/empty_gameplay/Gameplay.cpp @@ -0,0 +1,8 @@ +#include "Gameplay.hpp" + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime&) {} +nlohmann::json schema() { + return nlohmann::json::array(); +} +} // namespace faset::gameplay diff --git a/src/scripting/empty_gameplay/Gameplay.hpp b/src/scripting/empty_gameplay/Gameplay.hpp new file mode 100644 index 0000000..536ab72 --- /dev/null +++ b/src/scripting/empty_gameplay/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& runtime); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/src/scripting/project.cpp b/src/scripting/project.cpp new file mode 100644 index 0000000..e498850 --- /dev/null +++ b/src/scripting/project.cpp @@ -0,0 +1,162 @@ +#include + +#include +#include +#include +#include +#include +#include + +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 buffer{}; + while (stream) { + stream.read(buffer.data(), buffer.size()); + const auto count = static_cast(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 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 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(); + 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 diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp index 1799d16..19d2252 100644 --- a/tests/build_schema_tests.cpp +++ b/tests/build_schema_tests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -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() && + !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()); + const auto captured = scripting::loadLuaProject(lua_directory); + check(captured.enabled() && captured.sources.size() == 2 && + captured.fingerprint == + lua_build.result.at("lua_fingerprint").get() && + read_json(path_from_utf8(lua_build.result.at("schema").get())) + .at("lua_fingerprint") + .get() == 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()); + const auto package_manifest = read_json(packaged / "manifest.json"); + check(scripting::loadLuaProject(packaged).fingerprint == + package_manifest.at("lua_fingerprint").get() && + 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().starts_with("Scripts/")) { + ++packaged_sources; + check(sha256_file(packaged / path_from_utf8(file.at("path").get())) == + file.at("sha256").get(), + "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()); + 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) { diff --git a/tests/build_schema_tool.cpp b/tests/build_schema_tool.cpp index 9500d23..a1631f3 100644 --- a/tests/build_schema_tool.cpp +++ b/tests/build_schema_tool.cpp @@ -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()))) + 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()))) + 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"; diff --git a/tests/build_service_tests.cpp b/tests/build_service_tests.cpp index ea05455..5417bd1 100644 --- a/tests/build_service_tests.cpp +++ b/tests/build_service_tests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #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); diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 31ac747..1a97728 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -1,15 +1,28 @@ +#include #include #include #include +#include #include #include +#include #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 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()), 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 diff --git a/tests/editor_session_tests.cpp b/tests/editor_session_tests.cpp index 424345e..3e8b691 100644 --- a/tests/editor_session_tests.cpp +++ b/tests/editor_session_tests.cpp @@ -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().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'; diff --git a/tests/lua_cli_test.py b/tests/lua_cli_test.py new file mode 100644 index 0000000..ae02716 --- /dev/null +++ b/tests/lua_cli_test.py @@ -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") diff --git a/tests/lua_player_reload_test.py b/tests/lua_player_reload_test.py new file mode 100644 index 0000000..7ddc2e1 --- /dev/null +++ b/tests/lua_player_reload_test.py @@ -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") diff --git a/tests/lua_safety_tests.cpp b/tests/lua_safety_tests.cpp new file mode 100644 index 0000000..7e4ab4b --- /dev/null +++ b/tests/lua_safety_tests.cpp @@ -0,0 +1,215 @@ +#include +#include +#include +#include +#include + +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 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; + } +} diff --git a/tests/lua_tests.cpp b/tests/lua_tests.cpp new file mode 100644 index 0000000..041d739 --- /dev/null +++ b/tests/lua_tests.cpp @@ -0,0 +1,534 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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& 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 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; + } +} diff --git a/tools/lua/faset.lua b/tools/lua/faset.lua new file mode 100644 index 0000000..3360fb3 --- /dev/null +++ b/tools/lua/faset.lua @@ -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 + +---@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 +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 +---@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 Per-instance configuration: defaults plus scene overrides. +---@field state table 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 diff --git a/tools/lua/luarc.json b/tools/lua/luarc.json new file mode 100644 index 0000000..3ae089d --- /dev/null +++ b/tools/lua/luarc.json @@ -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"] +}