Add optional Lua scripting module, examples, and validation

This commit is contained in:
emil28092005
2026-09-18 11:28:50 +03:00
parent 06210aac23
commit 5a67735ff4
45 changed files with 3887 additions and 92 deletions
+96 -1
View File
@@ -3,6 +3,7 @@
#include <faset/core/io.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/editor/commands.hpp>
#include <faset/scripting/project.hpp>
#include <iostream>
#include <limits>
@@ -154,6 +155,12 @@ int test_main(int argc, char** argv) {
const auto previous_player = sha256_file(player);
const auto previous_schema = read_text(schema);
const auto previous_manifest = read_text(directory / "manifest.json");
check(!first.result.at("lua_enabled").get<bool>() &&
!fs::exists(directory / "project.faset.json"),
"C++-only build publishes no Lua sources or project manifest");
check(read_json(config.project_root / "configure-fixture.json").back() ==
"-DFASET_ENABLE_LUA=OFF",
"C++-only build explicitly disables the Lua VM in CMake");
authoring.replace_external_schemas(read_json(schema));
const auto previous_registry = authoring.schemas().manifest();
check(authoring.schemas().schema("game.mover").at("version") == 2,
@@ -251,8 +258,96 @@ int test_main(int argc, char** argv) {
check(rejected && authoring.schemas().manifest() == previous_registry,
"Cached-schema validation uses the same contract and preserves the registry");
}
atomic_write_json(config.project_root / "schema-fixture.json", valid);
auto project = read_json(config.project_root / "project.faset.json");
project["scripting"]["lua"]["scripts"] = Json::array({"Scripts/main.lua"});
atomic_write_json(config.project_root / "project.faset.json", project);
const auto lua_source =
"return faset.behavior { id = 'game.mover', version = 2, fields = {} }\n";
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 1}\n");
fs::remove(config.project_root / "Scripts/Gameplay.cpp");
fs::remove(config.project_root / "Scripts/Gameplay.hpp");
const auto lua_build = builds.wait(builds.start_build());
check(lua_build.state == "succeeded" && lua_build.result.at("lua_enabled") == true,
"Lua-only project builds without a Gameplay.cpp/Gameplay.hpp pair: " +
lua_build.error);
check(read_json(config.project_root / "configure-fixture.json").back() ==
"-DFASET_ENABLE_LUA=ON",
"Lua declaration enables the module in the native Player");
const auto lua_directory =
path_from_utf8(lua_build.result.at("directory").get<std::string>());
const auto captured = scripting::loadLuaProject(lua_directory);
check(captured.enabled() && captured.sources.size() == 2 &&
captured.fingerprint ==
lua_build.result.at("lua_fingerprint").get<std::string>() &&
read_json(path_from_utf8(lua_build.result.at("schema").get<std::string>()))
.at("lua_fingerprint")
.get<std::string>() == captured.fingerprint,
"Immutable source snapshot and merged schema share a Lua fingerprint");
check(read_json(config.project_root / "exporter-project-fixture.json").at("scripting") ==
project.at("scripting"),
"Schema exporter receives the captured project's Lua entries");
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 2}\n");
const auto changed = builds.wait(builds.start_build());
check(
changed.state == "succeeded" &&
changed.result.at("fingerprint") != lua_build.result.at("fingerprint") &&
changed.result.at("lua_fingerprint") != lua_build.result.at("lua_fingerprint"),
"Module-only Lua edits produce new build provenance without changing native fixtures");
check(scripting::loadLuaProject(lua_directory).fingerprint == captured.fingerprint,
"Later edits never mutate an already published Lua generation");
const auto lua_pointer = read_text(last_build);
for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) {
atomic_write(config.project_root / marker, "fixture\n");
const auto raced = builds.wait(builds.start_build());
check(raced.state == "failed" && read_text(last_build) == lua_pointer,
"Source or snapshot changes during schema export preserve the last good build");
fs::remove(config.project_root / marker);
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
}
const auto exported = builds.wait(builds.start_export(scene, root / "lua-export"));
check(exported.state == "succeeded",
"Lua export publishes a captured source package: " + exported.error);
const auto packaged = path_from_utf8(exported.result.at("directory").get<std::string>());
const auto package_manifest = read_json(packaged / "manifest.json");
check(scripting::loadLuaProject(packaged).fingerprint ==
package_manifest.at("lua_fingerprint").get<std::string>() &&
package_manifest.at("lua_enabled") == true &&
read_json(packaged / "Notices/dependencies.json").contains("lua") &&
fs::is_regular_file(packaged / "Notices/lua/LICENSE.txt"),
"Export contains source, fingerprint and the selected Lua runtime's license");
check(!fs::exists(packaged / "schema.json") &&
!fs::exists(packaged / "faset_schema_exporter") &&
!fs::exists(packaged / "faset_schema_exporter.exe") &&
!fs::exists(packaged / ".luarc.json") &&
!fs::exists(packaged / "Scripts/Gameplay.cpp"),
"Runtime export omits schema tools, editor configuration and C++ source");
std::size_t packaged_sources{};
for (const auto& file : package_manifest.at("files"))
if (file.at("path").get<std::string>().starts_with("Scripts/")) {
++packaged_sources;
check(sha256_file(packaged / path_from_utf8(file.at("path").get<std::string>())) ==
file.at("sha256").get<std::string>(),
"Every packaged source hash matches the export manifest");
}
check(packaged_sources == 2, "Entry and require module both appear in export provenance");
project.erase("scripting");
atomic_write_json(config.project_root / "project.faset.json", project);
atomic_write(config.project_root / "Scripts/Gameplay.cpp", "// C++ fixture\n");
atomic_write(config.project_root / "Scripts/Gameplay.hpp", "// C++ fixture\n");
const auto cpp_export = builds.wait(builds.start_export(scene, root / "cpp-export"));
check(cpp_export.state == "succeeded", "C++ export still succeeds: " + cpp_export.error);
const auto cpp_package =
path_from_utf8(cpp_export.result.at("directory").get<std::string>());
check(!fs::exists(cpp_package / "Scripts") &&
!fs::exists(cpp_package / "project.faset.json") &&
!read_json(cpp_package / "Notices/dependencies.json").contains("lua") &&
read_json(config.project_root / "configure-fixture.json").back() ==
"-DFASET_ENABLE_LUA=OFF",
"Removing Lua declarations drops scripts, notices and the Lua link dependency");
std::cout << "Valid v2 schema and atomic rejection of " << invalid.size()
<< " malformed metadata generations passed\n";
<< " malformed metadata generations; Lua snapshots and export contracts passed\n";
fs::remove_all(root);
return 0;
} catch (const std::exception& error) {
+31 -1
View File
@@ -7,10 +7,33 @@ namespace fs = std::filesystem;
using namespace faset;
int tool_main(int argc, char** argv) {
try {
if (argc == 3 && std::string_view(argv[1]) == "--output") {
if (argc >= 3 && std::string_view(argv[1]) == "--output") {
if (argc == 5 && std::string_view(argv[3]) == "--project") {
const auto snapshot = path_from_utf8(argv[4]);
const auto project = read_json(snapshot / "project.faset.json");
for (const auto& name : project.at("scripting").at("lua").at("scripts"))
if (!fs::is_regular_file(snapshot / path_from_utf8(name.get<std::string>())))
throw std::runtime_error(
"Schema exporter did not receive captured sources");
atomic_write_json("exporter-project-fixture.json", project);
if (fs::exists("mutate-lua-during-build"))
atomic_write("Scripts/main.lua", "-- changed while schema was exporting\n");
if (fs::exists("mutate-lua-snapshot"))
atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n");
}
atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
return 0;
}
if (argc >= 2 && std::string_view(argv[1]) == "--validate") {
if (fs::exists("project.faset.json")) {
const auto manifest = read_json("project.faset.json");
for (const auto& name : manifest.at("scripting").at("lua").at("scripts"))
if (!fs::is_regular_file(path_from_utf8(name.get<std::string>())))
throw std::runtime_error("Packaged Player is missing a Lua entry source");
}
std::cout << "Native packaging fixture validated\n";
return 0;
}
if (argc > 2 && std::string_view(argv[1]) == "--build")
return 0;
fs::path build;
@@ -19,8 +42,15 @@ int tool_main(int argc, char** argv) {
build = path_from_utf8(argv[i + 1]);
if (build.empty())
throw std::runtime_error("Fixture expects CMake configure or SchemaExporter arguments");
Json arguments = Json::array();
for (int i = 1; i < argc; ++i)
arguments.push_back(argv[i]);
atomic_write_json("configure-fixture.json", arguments);
fs::create_directories(build / "shaders");
atomic_write(build / "CMakeCache.txt", "Native schema publication fixture\n");
for (const auto* name : {"sdl3", "entt", "box2d", "box3d", "json", "stb"})
atomic_write(build / "_deps" / (std::string(name) + "-src") / "LICENSE.txt",
"Synthetic dependency notice for packaging tests only.\n");
const auto self = fs::absolute(path_from_utf8(argv[0]));
#ifdef _WIN32
constexpr auto suffix = ".exe";
+83
View File
@@ -7,6 +7,7 @@
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <iostream>
#include <thread>
#ifndef _WIN32
@@ -41,6 +42,87 @@ Json scene(int dimension) {
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}};
}
void lua_project_contracts(const fs::path& root) {
fs::create_directories(root);
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
Json manifest{{"format", "faset.project"}, {"version", 1}};
const auto save_manifest = [&] { atomic_write_json(root / "project.faset.json", manifest); };
save_manifest();
require(!scripting::loadLuaProject(root).enabled(), "C++ manifest requires no Lua sources");
atomic_write(root / "Scripts/main.lua", "return {value = 1}\n");
atomic_write(root / "Scripts/lib/util.lua", "return {answer = 42}\n");
atomic_write(root / "Scripts/Gameplay.cpp", "// Not a Lua module\n");
manifest["scripting"]["lua"]["scripts"] = Json::array({"Scripts/main.lua"});
save_manifest();
const auto original = scripting::loadLuaProject(root);
require(original.enabled() && original.sources.size() == 2 && original.fingerprint.size() == 64,
"Capture entry script and transitive module candidates, not C++");
require(scripting::loadLuaProject(root).fingerprint == original.fingerprint,
"Lua fingerprint is deterministic");
atomic_write(root / "Scripts/lib/util.lua", "return {answer = 43}\n");
require(scripting::loadLuaProject(root).fingerprint != original.fingerprint,
"Changes to non-entry require modules invalidate the Lua fingerprint");
const auto target = root.parent_path() / "lua-snapshot";
scripting::writeLuaSources(original, target);
atomic_write_json(target / "project.faset.json", manifest);
require(scripting::loadLuaProject(target).fingerprint == original.fingerprint &&
read_text(target / "Scripts/lib/util.lua") == "return {answer = 42}\n",
"Publishing writes captured bytes rather than rereading live scripts");
const auto valid_manifest = manifest;
auto rejected = [&](const auto& operation) {
try {
operation();
} catch (const std::exception&) {
return true;
}
return false;
};
for (const auto& entries :
{Json::array({"Scripts/main.lua", "Scripts/main.lua"}),
Json::array({"Scripts/../main.lua"}), Json::array({"Scripts/missing.lua"}),
Json::array({"Scripts/Gameplay.cpp"}), Json::array({"/Scripts/main.lua"}),
Json::array({"Scripts\\main.lua"}), Json::array({"Scripts/./main.lua"}),
Json::array({"Scripts//main.lua"}), Json::array({"C:/Scripts/main.lua"}),
Json::array({12}), Json("Scripts/main.lua")}) {
manifest["scripting"]["lua"]["scripts"] = entries;
save_manifest();
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
"Lua rejects malformed, duplicate, missing and escaping entry paths");
}
manifest = valid_manifest;
manifest["scripting"]["lua"]["scripts"].push_back("Scripts/lib/util.lua");
save_manifest();
const auto with_second_entry = scripting::loadLuaProject(root).fingerprint;
manifest["scripting"]["lua"]["scripts"] =
Json::array({"Scripts/lib/util.lua", "Scripts/main.lua"});
save_manifest();
require(scripting::loadLuaProject(root).fingerprint != with_second_entry,
"Entry order is part of the source fingerprint");
manifest = valid_manifest;
save_manifest();
atomic_write(root / "Scripts/too-large.lua", std::string(1024 * 1024 + 1, ' '));
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
"Source size is bounded before code execution");
fs::remove(root / "Scripts/too-large.lua");
auto corrupt = original;
corrupt.sources["../outside.lua"] = "return {}";
require(rejected([&] { scripting::writeLuaSources(corrupt, target); }),
"Writing an externally supplied snapshot validates paths too");
std::error_code symlink_error;
fs::create_symlink(root / "Scripts/main.lua", root / "Scripts/link.lua", symlink_error);
if (!symlink_error) {
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
"Lua source snapshots reject even in-project symlink aliases");
fs::remove(root / "Scripts/link.lua");
fs::create_directory_symlink(root / "Scripts", target / "linked", symlink_error);
if (!symlink_error)
require(rejected([&] { scripting::writeLuaSources(original, target / "linked"); }),
"Snapshot destination root cannot be a symlink");
}
manifest["scripting"]["lua"]["scripts"] = Json::array();
save_manifest();
require(!scripting::loadLuaProject(root).enabled(), "Empty Lua declaration links no Lua VM");
}
int integration(const fs::path& root) {
fs::create_directories(root);
editor::BuildConfig config;
@@ -296,6 +378,7 @@ int test_main(int argc, char** argv) {
fs::path temporary = fs::temp_directory_path() / path_from_utf8("Faset Café 世界 " + new_id());
try {
fs::create_directories(temporary);
lua_project_contracts(temporary / "lua-project");
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
const auto executable = temporary / original_executable.filename();
fs::copy_file(original_executable, executable);
+54 -2
View File
@@ -1,15 +1,28 @@
#include <chrono>
#include <faset/core/error.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <iostream>
#include <set>
#include <thread>
#define CHECK(x) \
do { \
if (!(x)) \
throw std::runtime_error("Check failed: " #x); \
} while (false)
int main() {
int test_main(int argc, char** argv) {
if (argc > 2 && std::string(argv[1]) == "--detached-child") {
faset::Json arguments = faset::Json::array();
for (int i = 3; i < argc; ++i)
arguments.push_back(argv[i]);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
faset::atomic_write_json(
faset::path_from_utf8(argv[2]),
{{"args", arguments}, {"cwd", faset::path_to_utf8(std::filesystem::current_path())}});
return 0;
}
const auto directory =
std::filesystem::temp_directory_path() / ("faset-core-" + faset::new_id());
try {
@@ -85,9 +98,39 @@ int main() {
rejected = true;
}
CHECK(rejected);
const auto external_result = directory / faset::path_from_utf8("Editor 世界.json");
const std::vector<std::string> arguments = {
faset::path_to_utf8(std::filesystem::absolute(faset::path_from_utf8(argv[0]))),
"--detached-child",
faset::path_to_utf8(external_result),
"space argument",
"quote\"backslash\\",
"$(touch not-executed); & |",
"",
"Café 世界 Привет 😀"};
faset::launch_detached(arguments, directory);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (!std::filesystem::exists(external_result) &&
std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
CHECK(std::filesystem::is_regular_file(external_result));
const auto external = faset::read_json(external_result);
CHECK(external.at("args") ==
faset::Json::array({"space argument", "quote\"backslash\\",
"$(touch not-executed); & |", "", "Café 世界 Привет 😀"}));
CHECK(std::filesystem::equivalent(
faset::path_from_utf8(external.at("cwd").get<std::string>()), directory));
CHECK(!std::filesystem::exists(directory / "not-executed"));
rejected = false;
try {
faset::launch_detached({"faset-nonexistent-editor-" + faset::new_id()}, directory);
} catch (const std::exception&) {
rejected = true;
}
CHECK(rejected);
std::filesystem::remove_all(faset::native_io_path(directory));
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
"boundaries passed\n";
"boundaries and detached literal-argv launch passed\n";
return 0;
} catch (const std::exception& error) {
std::filesystem::remove_all(faset::native_io_path(directory));
@@ -95,3 +138,12 @@ int main() {
return 1;
}
}
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) {
return faset::run_utf8_main(argc, argv, test_main);
}
#else
int main(int argc, char** argv) {
return test_main(argc, argv);
}
#endif
+46 -2
View File
@@ -55,6 +55,8 @@ int main() {
}
auto external = session.project();
external["custom_tool"] = {{"keep", true}};
external["scripting"] = {{"lua", {{"scripts", Json::array()}}}};
external["editor"] = {{"script_editor", {"zed", "{file}"}}};
atomic_write_json(root / "project.faset.json", external);
rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}},
"revision.conflict");
@@ -64,6 +66,48 @@ int main() {
{"settings", {{"name", "Preserved"}}}});
require(changed.at("settings").at("custom_tool").at("keep") == true, "test",
"Saving settings erased unknown project metadata");
require(changed.at("settings").at("scripting") == external.at("scripting") &&
changed.at("settings").at("editor") == external.at("editor"),
"test", "Project settings erased Lua configuration or external editor command");
const auto setup = commands.call("faset_lua_setup", Json::object());
require(setup.at("configuration_created") == true &&
std::filesystem::is_regular_file(root / ".faset/lua/faset.lua") &&
read_json(root / ".luarc.json").at("runtime.version") == "Lua 5.4",
"test", "LuaLS setup did not install annotations and configuration");
const Json custom_luarc = {{"runtime.version", "Lua 5.4"},
{"workspace.library", {"Custom/Lua"}},
{"custom_setting", true}};
atomic_write_json(root / ".luarc.json", custom_luarc);
require(commands.call("faset_lua_setup", Json::object()).at("configuration_created") ==
false &&
read_json(root / ".luarc.json") == custom_luarc,
"test", "LuaLS setup overwrote the user's configuration");
auto reject_command = [&](const std::string& name, const Json& args,
const std::string& code) {
try {
commands.call(name, args);
} catch (const Error& error) {
require(error.json().at("code") == code, "test",
"Unexpected error for invalid Lua editor command");
return;
}
throw std::runtime_error("Invalid Lua editor command was accepted");
};
reject_command("faset_lua_refresh", Json::object(), "lua.disabled");
reject_command("faset_lua_reload", Json::object(), "play.not_running");
reject_command("faset_script_open", {{"path", "Scripts/Gameplay.cpp"}}, "lua.source");
atomic_write(root / "Scripts/behavior.lua", "return faset.behavior { id = 'game.test' }\n");
reject_command("faset_script_open",
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
"lua.editor");
auto bad_lua_project = session.project();
bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}};
atomic_write_json(root / "project.faset.json", bad_lua_project);
const auto invalid_lua_status = commands.call("faset_schema_status", Json::object());
require(invalid_lua_status.at("stale") == true &&
!invalid_lua_status.at("error").get<std::string>().empty(),
"test", "Invalid Lua manifest did not mark schema stale with diagnostics");
external["version"] = 999;
atomic_write_json(root / "project.faset.json", external);
bool rejected = false;
@@ -74,8 +118,8 @@ int main() {
}
require(rejected, "test", "Future project version was reinterpreted");
std::filesystem::remove_all(root);
std::cout << "Project settings validation, save, external revision conflicts and opaque "
"metadata passed\n";
std::cout << "Project settings validation, revision conflicts, opaque metadata, LuaLS "
"configuration preservation and Lua editor commands passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << "\nFixture retained at " << root << '\n';
+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;
}
}