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