3 Commits
Author SHA1 Message Date
emil28092005 4cc58d8c59 Configure LuaLS for single-file script workspaces
Native and manual checks / native (ubuntu-24.04) (push) Waiting to run
Native and manual checks / native (windows-2025) (push) Waiting to run
Native and manual checks / manual (push) Waiting to run
Windows editor and software Vulkan / windows-graphics (push) Waiting to run
2026-09-18 11:57:23 +03:00
emil28092005 5a67735ff4 Add optional Lua scripting module, examples, and validation 2026-09-18 11:28:50 +03:00
Emil 06210aac23 Preserve artifact provenance across Git text normalization 2026-09-18 06:24:57 +03:00
50 changed files with 3985 additions and 122 deletions
+6
View File
@@ -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
View File
@@ -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()
+2 -2
View File
@@ -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
+170 -37
View File
@@ -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,28 +182,35 @@ 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
"[--headless] [--capture PATH.ppm] [--validate] [--control PATH] " << "faset_player [--scene PATH] [--assets CACHE] [--frames N] "
"[--profile PATH.json] [--debug-physics]\n" "[--headless] [--capture PATH.ppm] [--validate] [--control PATH] "
"No --scene: open scene.fscene beside the executable. CACHE contains " "[--profile PATH.json] [--debug-physics] [--project ROOT] "
"assets/<id>/.\n" "[--watch-lua]\n"
"Headless uses offscreen Vulkan; --frames uses the configured fixed " "No --scene: open scene.fscene beside the executable. CACHE contains "
"simulation delta.\n" "assets/<id>/.\n"
"--validate checks scene/resources on CPU without gameplay callbacks " "Headless uses offscreen Vulkan; --frames uses the configured fixed "
"or Vulkan initialization.\n" "simulation delta.\n"
"--control is an optional editor mailbox for pause/resume/step/stop, " "--validate checks scene/resources on CPU without gameplay callbacks "
"without world queries.\n" "or Vulkan initialization.\n"
"--profile requires explicit --frames 1..100000; measured durations " "--control is an optional editor mailbox for pause/resume/step/stop, "
"include the first frame and renderer GPU waits/readback.\n" "without world queries.\n"
"Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, " "--project loads Lua declared in project.faset.json; packaged projects "
"N single-step, F3 physics boxes, Escape quit.\n"; "are discovered beside the scene or executable.\n"
"--watch-lua enables development-only script reload (or control "
"reload-lua): the scene restarts, runtime state is not preserved.\n"
"--profile requires explicit --frames 1..100000; measured durations "
"include the first frame and renderer GPU waits/readback.\n"
"Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, "
"N single-step, F3 physics boxes, Escape quit.\n";
return 0; return 0;
} }
if (!options.insert(arg).second) if (!options.insert(arg).second)
@@ -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");
+28 -4
View File
@@ -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 -1
View File
@@ -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)
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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",
+5
View File
@@ -327,6 +327,11 @@ production build, 318 local Markdown links, retained Windows evidence hashes, al
validation JSON records and `git diff --check`. The engine-source diff from the validation JSON records and `git diff --check`. The engine-source diff from the
accepted commit is empty; no untested engine changes were bundled into publication. accepted commit is empty; no untested engine changes were bundled into publication.
The post-publication documentation check also verified hashes against Git blobs,
which normalize Windows JSON line endings to LF. Evidence indices now identify both
the retained repository bytes and original CRLF artifacts explicitly. This follow-up
changes only documentation metadata and preserves the first MVP tag and engine code.
Known coverage limits remain: real OS IME composition, movement between physical Known coverage limits remain: real OS IME composition, movement between physical
monitors with different scales, native Wayland programmatic restore and additional monitors with different scales, native Wayland programmatic restore and additional
GPU/driver families. Widget composition/DPI, SDL text-input boundaries, XWayland and GPU/driver families. Widget composition/DPI, SDL text-input boundaries, XWayland and
+23
View File
@@ -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.
+20 -1
View File
@@ -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
+7 -4
View File
@@ -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
+7 -3
View File
@@ -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
+238
View File
@@ -0,0 +1,238 @@
# 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**.
It also creates `Scripts/.luarc.json` for editors that open an individual Lua file
with `Scripts` as the workspace root. Existing configuration files are preserved.
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.
+55
View File
@@ -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.
@@ -15,4 +15,10 @@ The [toolchain](final-source/toolchain.json) and [probe](final-source/probe.json
Original detailed job logs and raw frame profiles are available in the linked CI artifact while its retention period lasts. This folder keeps the compact acceptance evidence and hashes of the original CTest/CI logs. Original detailed job logs and raw frame profiles are available in the linked CI artifact while its retention period lasts. This folder keeps the compact acceptance evidence and hashes of the original CTest/CI logs.
Git stores these JSON records with LF line endings. Each retained-file `sha256` and
`size` describes those repository bytes; where the original Windows artifact used
CRLF, `original_artifact_sha256` and `original_artifact_size` preserve that separate
byte identity. The documentation correction after the first MVP tag records this
normalization explicitly; it does not change the test outcomes or packaged games.
The earlier [checkpoint 5 record](checkpoint5/evidence.json) preserves the independently successful run of `0f34b036313c011861dbfd5828ed45c4f7940b05`. The final source run additionally covers the relocated importer metadata fix through `assets_pipeline`, followed by the complete export matrix again. [2D capture](final-source/collect-2d.png) · [3D capture](final-source/collect-3d.png). The earlier [checkpoint 5 record](checkpoint5/evidence.json) preserves the independently successful run of `0f34b036313c011861dbfd5828ed45c4f7940b05`. The final source run additionally covers the relocated importer metadata fix through `assets_pipeline`, followed by the complete export matrix again. [2D capture](final-source/collect-2d.png) · [3D capture](final-source/collect-3d.png).
@@ -178,13 +178,17 @@
}, },
{ {
"path": "collect-2d-run.json", "path": "collect-2d-run.json",
"sha256": "9dda395b5012051bfa225be27577bde0eff9d56ccc5f7e64ae8ed7dbd9748d02", "sha256": "afd99e711f732aff500fdb5ff710c4ecfc9fa7185ac376880959c457692c21ea",
"size": 1011 "size": 993,
"original_artifact_sha256": "9dda395b5012051bfa225be27577bde0eff9d56ccc5f7e64ae8ed7dbd9748d02",
"original_artifact_size": 1011
}, },
{ {
"path": "collect-2d-validate.json", "path": "collect-2d-validate.json",
"sha256": "1789f7b2d8de3bd55be3974d6b1e8fbc76424b5c73633e0a2052fb07482ce474", "sha256": "3cc2e458a2ac3c8e0c08cb6475ebc7a8a9c9fcc3580d423dcca60f690db9afda",
"size": 445 "size": 433,
"original_artifact_sha256": "1789f7b2d8de3bd55be3974d6b1e8fbc76424b5c73633e0a2052fb07482ce474",
"original_artifact_size": 445
}, },
{ {
"path": "collect-2d.png", "path": "collect-2d.png",
@@ -198,13 +202,17 @@
}, },
{ {
"path": "collect-3d-run.json", "path": "collect-3d-run.json",
"sha256": "0fd945b0ea7d8373832db5ef017b1d57155ba8a26fd245e43d45732a0aacb5f9", "sha256": "a19ce25b92a84eb04a5f0a50907c661251f73bc25bed94687ea0a7ae03dbcc88",
"size": 1128 "size": 1110,
"original_artifact_sha256": "0fd945b0ea7d8373832db5ef017b1d57155ba8a26fd245e43d45732a0aacb5f9",
"original_artifact_size": 1128
}, },
{ {
"path": "collect-3d-validate.json", "path": "collect-3d-validate.json",
"sha256": "3c6f05871ac8d462505c0f4021cb18d9740060955bad22aead701a4dec8bebb0", "sha256": "199938c31d5fbeda2ee94148a00ce7c6d67f7239b216fb39f171313b36e90ad4",
"size": 562 "size": 550,
"original_artifact_sha256": "3c6f05871ac8d462505c0f4021cb18d9740060955bad22aead701a4dec8bebb0",
"original_artifact_size": 562
}, },
{ {
"path": "collect-3d.png", "path": "collect-3d.png",
@@ -213,13 +221,17 @@
}, },
{ {
"path": "playable-report.json", "path": "playable-report.json",
"sha256": "d754700b5bfb23e3e5f6f78cc77336daea0503d71fe82bec7c80454f194ac293", "sha256": "bfb1ee2a6632978ad95428025fddfed77ec330256acd68b0152140beb6fe8a41",
"size": 14529 "size": 14094,
"original_artifact_sha256": "d754700b5bfb23e3e5f6f78cc77336daea0503d71fe82bec7c80454f194ac293",
"original_artifact_size": 14529
}, },
{ {
"path": "probe.json", "path": "probe.json",
"sha256": "0c85c12510dbb0d895faefb7d14f26935a5add675cf0477b29f543d72eed40d6", "sha256": "fbc4c5f01a5d23c901fbdab366537da331002709bfbce42e06a84b1a64a1b396",
"size": 1019 "size": 987,
"original_artifact_sha256": "0c85c12510dbb0d895faefb7d14f26935a5add675cf0477b29f543d72eed40d6",
"original_artifact_size": 1019
}, },
{ {
"path": "tests.json", "path": "tests.json",
@@ -228,8 +240,11 @@
}, },
{ {
"path": "toolchain.json", "path": "toolchain.json",
"sha256": "969162d1f994724ff6f98d20353e62e3881477ebce98759ca325c8febb175176", "sha256": "daf1ce634e438ff9cd3a84a2c21313f3530279dfb27f15acf8b217833e06548d",
"size": 937 "size": 914,
"original_artifact_sha256": "969162d1f994724ff6f98d20353e62e3881477ebce98759ca325c8febb175176",
"original_artifact_size": 937
} }
] ],
"retained_file_hash_basis": "Repository bytes with LF text line endings. Where normalization changed original Windows artifact bytes, original_artifact_sha256 and original_artifact_size preserve their original provenance."
} }
@@ -178,13 +178,17 @@
}, },
{ {
"path": "collect-2d-run.json", "path": "collect-2d-run.json",
"sha256": "9dcc203502d2504ffcc9dcfed4ecde118bda954ee8d4604e0b6992839390b731", "sha256": "c65e56ff39059612afb9456f26c2bd0674e883057f60551216875643d6a349cd",
"size": 1011 "size": 993,
"original_artifact_sha256": "9dcc203502d2504ffcc9dcfed4ecde118bda954ee8d4604e0b6992839390b731",
"original_artifact_size": 1011
}, },
{ {
"path": "collect-2d-validate.json", "path": "collect-2d-validate.json",
"sha256": "ffd23bb53b1d0f7da885ec40f6ab932f3aee3cc17afc3c620abc16ec4a661d4c", "sha256": "9d94363e13ac5df29f2a236356384564dcab3b73115d8e65f02b891ee655805f",
"size": 446 "size": 434,
"original_artifact_sha256": "ffd23bb53b1d0f7da885ec40f6ab932f3aee3cc17afc3c620abc16ec4a661d4c",
"original_artifact_size": 446
}, },
{ {
"path": "collect-2d.png", "path": "collect-2d.png",
@@ -198,13 +202,17 @@
}, },
{ {
"path": "collect-3d-run.json", "path": "collect-3d-run.json",
"sha256": "cd21db5286b96f4d4a9a3e381563797230d0b1d8cdd8fbfb644eb272e9b82af6", "sha256": "3dbf47e49bb1a27f68e1ffd222b2251a270469942cb20b08415f619a33b3c6c6",
"size": 1127 "size": 1109,
"original_artifact_sha256": "cd21db5286b96f4d4a9a3e381563797230d0b1d8cdd8fbfb644eb272e9b82af6",
"original_artifact_size": 1127
}, },
{ {
"path": "collect-3d-validate.json", "path": "collect-3d-validate.json",
"sha256": "6dedaca4e4ffa573b7991856e5f3464bf93f6e825192ad4c4d35693d731531ad", "sha256": "f15afce2a5248a0e74179bc69a4b653976ecc07ad7e28cb29669b07bfe89297c",
"size": 562 "size": 550,
"original_artifact_sha256": "6dedaca4e4ffa573b7991856e5f3464bf93f6e825192ad4c4d35693d731531ad",
"original_artifact_size": 562
}, },
{ {
"path": "collect-3d.png", "path": "collect-3d.png",
@@ -213,13 +221,17 @@
}, },
{ {
"path": "playable-report.json", "path": "playable-report.json",
"sha256": "3783533ba50b9b4b2fe29fa3e13cbaf7c43b96d29dce8365fa72ae014bc960f1", "sha256": "e3e6743832ce6fd216b3352609d80ce1de63ec1c2c58c58034bd361967cb7a5d",
"size": 14528 "size": 14093,
"original_artifact_sha256": "3783533ba50b9b4b2fe29fa3e13cbaf7c43b96d29dce8365fa72ae014bc960f1",
"original_artifact_size": 14528
}, },
{ {
"path": "probe.json", "path": "probe.json",
"sha256": "0c85c12510dbb0d895faefb7d14f26935a5add675cf0477b29f543d72eed40d6", "sha256": "fbc4c5f01a5d23c901fbdab366537da331002709bfbce42e06a84b1a64a1b396",
"size": 1019 "size": 987,
"original_artifact_sha256": "0c85c12510dbb0d895faefb7d14f26935a5add675cf0477b29f543d72eed40d6",
"original_artifact_size": 1019
}, },
{ {
"path": "tests.json", "path": "tests.json",
@@ -228,8 +240,11 @@
}, },
{ {
"path": "toolchain.json", "path": "toolchain.json",
"sha256": "969162d1f994724ff6f98d20353e62e3881477ebce98759ca325c8febb175176", "sha256": "daf1ce634e438ff9cd3a84a2c21313f3530279dfb27f15acf8b217833e06548d",
"size": 937 "size": 914,
"original_artifact_sha256": "969162d1f994724ff6f98d20353e62e3881477ebce98759ca325c8febb175176",
"original_artifact_size": 937
} }
] ],
"retained_file_hash_basis": "Repository bytes with LF text line endings. Where normalization changed original Windows artifact bytes, original_artifact_sha256 and original_artifact_size preserve their original provenance."
} }
+19
View File
@@ -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.
+71
View File
@@ -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": []
}
+27
View File
@@ -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
+47
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
local motion = {}
function motion.bob(time, frequency, amplitude)
return math.sin(time * frequency * 2 * math.pi) * amplitude
end
return motion
+13
View File
@@ -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"]
}
}
}
+5
View File
@@ -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
+37
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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
View File
@@ -103,6 +103,109 @@ std::filesystem::path find_executable(const std::string& name) {
return resolve_program(name, path ? path : "", std::filesystem::current_path()); 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{};
+69 -12
View File
@@ -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")},
+29 -8
View File
@@ -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);
+119 -7
View File
@@ -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 {
return {{"loaded", schema_loaded_}, try {
{"stale", !schema_loaded_ || schema_source_signature_ != source_signature() || const auto signature = source_signature();
!schema_error_.empty()}, return {{"loaded", schema_loaded_},
{"error", schema_error_}}; {"stale", !schema_loaded_ || schema_source_signature_ != signature ||
!schema_error_.empty()},
{"error", schema_error_}};
} catch (const std::exception& error) {
return {{"loaded", schema_loaded_}, {"stale", true}, {"error", error.what()}};
}
} }
Json Session::jobs() const { Json 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,104 @@ 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");
const auto scripts_configuration =
project_path(config_.project_root, "Scripts/.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"));
// Zed can open a script as a single-file workspace rooted at Scripts.
// Give that workspace its own relative path to the API declarations.
const bool create_scripts_configuration =
!std::filesystem::exists(scripts_configuration);
if (create_scripts_configuration)
atomic_write_json(scripts_configuration,
read_json(source / "luarc-scripts.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},
{"scripts_configuration_created", create_scripts_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
+162
View File
@@ -0,0 +1,162 @@
#include <faset/scripting/project.hpp>
#include <array>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <fstream>
#include <set>
#include <stdexcept>
namespace faset::scripting {
namespace fs = std::filesystem;
namespace {
constexpr std::size_t max_source_bytes = 1024 * 1024;
constexpr std::size_t max_total_bytes = 16 * 1024 * 1024;
constexpr std::size_t max_sources = 4096;
constexpr std::size_t max_directory_entries = 16384;
fs::path source_path(const std::string& name) {
if (name.size() > 1024 || name.find('\0') != std::string::npos ||
name.find('\\') != std::string::npos || name.find(':') != std::string::npos)
throw std::runtime_error("Lua source path must use project-relative forward slashes: " +
name);
const auto path = path_from_utf8(name);
if (path.is_absolute() || path.has_root_path() || path.empty() || *path.begin() != "Scripts" ||
path.extension() != ".lua" || generic_path_to_utf8(path.lexically_normal()) != name)
throw std::runtime_error("Lua sources must be normalized .lua paths below Scripts/: " +
name);
for (const auto& part : path)
if (part == "." || part == ".." || part.empty())
throw std::runtime_error("Lua source path contains traversal: " + name);
return path;
}
void no_symlinks(const fs::path& root, const fs::path& relative = {}) {
auto current = root;
if (fs::is_symlink(fs::symlink_status(current)))
throw std::runtime_error("Lua project root must not be a symlink");
for (const auto& part : relative) {
current /= part;
if (fs::is_symlink(fs::symlink_status(current)))
throw std::runtime_error("Lua project paths must not contain symlinks: " +
path_to_utf8(current));
}
}
std::string bounded_text(const fs::path& path) {
if (!fs::is_regular_file(path) || fs::file_size(path) > max_source_bytes)
throw std::runtime_error("Lua source or manifest must be a regular file at most 1 MiB: " +
path_to_utf8(path));
std::ifstream stream(native_io_path(path), std::ios::binary);
if (!stream)
throw std::runtime_error("Cannot read Lua project file: " + path_to_utf8(path));
std::string value;
std::array<char, 16384> buffer{};
while (stream) {
stream.read(buffer.data(), buffer.size());
const auto count = static_cast<std::size_t>(stream.gcount());
if (value.size() + count > max_source_bytes)
throw std::runtime_error("Lua project file exceeds 1 MiB: " + path_to_utf8(path));
value.append(buffer.data(), count);
}
if (stream.bad())
throw std::runtime_error("Cannot read Lua project file: " + path_to_utf8(path));
return value;
}
void validate_snapshot(const LuaProject& project) {
if (project.scripts.size() > max_sources || project.sources.size() > max_sources)
throw std::runtime_error("Lua project exceeds 4096 source files");
std::set<std::string> entries;
for (const auto& name : project.scripts) {
(void)source_path(name);
if (!entries.insert(name).second || !project.sources.contains(name))
throw std::runtime_error("Duplicate or missing Lua entry source: " + name);
}
std::size_t bytes{};
for (const auto& [name, source] : project.sources) {
(void)source_path(name);
bytes += source.size();
if (source.size() > max_source_bytes || bytes > max_total_bytes)
throw std::runtime_error(
"Lua project exceeds source size limits (1 MiB/file, 16 MiB total)");
}
}
} // namespace
LuaProject loadLuaProject(const fs::path& projectRoot) {
LuaProject result;
no_symlinks(projectRoot);
const auto manifest = projectRoot / "project.faset.json";
no_symlinks(projectRoot, "project.faset.json");
if (!fs::exists(manifest))
return result;
const auto project = Json::parse(bounded_text(manifest));
if (!project.is_object())
throw std::runtime_error("Lua project manifest must be an object");
if (!project.contains("scripting"))
return result;
const auto& scripting = project.at("scripting");
if (!scripting.is_object())
throw std::runtime_error("Project scripting must be an object");
if (!scripting.contains("lua"))
return result;
const auto& lua = scripting.at("lua");
if (!lua.is_object() || !lua.contains("scripts") || !lua.at("scripts").is_array())
throw std::runtime_error("Project scripting.lua.scripts must be an array of entry paths");
if (lua.at("scripts").size() > max_sources)
throw std::runtime_error("Lua project exceeds 4096 entry scripts");
std::set<std::string> unique;
for (const auto& entry : lua.at("scripts")) {
if (!entry.is_string())
throw std::runtime_error("Lua entry paths must be strings");
auto name = entry.get<std::string>();
const auto path = source_path(name);
no_symlinks(projectRoot, path);
if (!unique.insert(name).second)
throw std::runtime_error("Duplicate Lua entry source: " + name);
if (!fs::is_regular_file(projectRoot / path))
throw std::runtime_error("Missing Lua entry source: " + name);
result.scripts.push_back(std::move(name));
}
if (!result.enabled())
return result;
no_symlinks(projectRoot, "Scripts");
std::size_t count{}, total{};
for (auto it = fs::recursive_directory_iterator(projectRoot / "Scripts");
it != fs::recursive_directory_iterator(); ++it) {
if (++count > max_directory_entries || it.depth() > 32)
throw std::runtime_error("Lua Scripts directory exceeds traversal limits");
if (it->is_symlink())
throw std::runtime_error("Lua Scripts directory contains a symlink: " +
path_to_utf8(it->path()));
if (it->path().extension() != ".lua")
continue;
const auto name = generic_path_to_utf8(it->path().lexically_relative(projectRoot));
(void)source_path(name);
auto source = bounded_text(it->path());
total += source.size();
if (total > max_total_bytes || result.sources.size() >= max_sources)
throw std::runtime_error(
"Lua project exceeds source limits (4096 files, 16 MiB total)");
result.sources.emplace(name, std::move(source));
}
validate_snapshot(result);
// JSON supplies unambiguous framing; std::map makes source ordering stable.
result.fingerprint = sha256(Json{
{"format", "faset.lua-sources.v1"},
{"scripts", result.scripts},
{"sources", result.sources}}.dump());
return result;
}
void writeLuaSources(const LuaProject& project, const fs::path& targetRoot) {
validate_snapshot(project);
no_symlinks(targetRoot);
// Validate all destinations before writing any source.
for (const auto& [name, source] : project.sources)
no_symlinks(targetRoot, source_path(name));
for (const auto& [name, source] : project.sources)
atomic_write(targetRoot / source_path(name), source);
}
} // namespace faset::scripting
+96 -1
View File
@@ -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) {
+31 -1
View File
@@ -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";
+83
View File
@@ -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
View File
@@ -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
+54 -2
View File
@@ -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,56 @@ 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 &&
setup.at("scripts_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");
require(read_json(root / "Scripts/.luarc.json").at("workspace.library") ==
Json::array({"../.faset/lua"}),
"test", "Single-file Lua workspace cannot find Faset annotations");
const Json custom_luarc = {{"runtime.version", "Lua 5.4"},
{"workspace.library", {"Custom/Lua"}},
{"custom_setting", true}};
const Json custom_scripts_luarc = {{"workspace.library", {"../Custom/Lua"}}};
atomic_write_json(root / ".luarc.json", custom_luarc);
atomic_write_json(root / "Scripts/.luarc.json", custom_scripts_luarc);
const auto repeated_setup = commands.call("faset_lua_setup", Json::object());
require(repeated_setup.at("configuration_created") == false &&
repeated_setup.at("scripts_configuration_created") == false &&
read_json(root / ".luarc.json") == custom_luarc &&
read_json(root / "Scripts/.luarc.json") == custom_scripts_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 +126,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';
+105
View File
@@ -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")
+99
View File
@@ -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")
+215
View File
@@ -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;
}
}
+534
View File
@@ -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;
}
}
+146
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
{
"runtime.version": "Lua 5.4",
"runtime.path": ["?.lua", "?/init.lua"],
"workspace.library": ["../.faset/lua"],
"workspace.checkThirdParty": false,
"diagnostics.globals": ["faset"]
}
+7
View File
@@ -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"]
}