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
+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
project launcher and create or select a project using the native interface.
Use **Build C++** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
The Player runs separately. Stop it before changing and rebuilding C++ gameplay.
See [MCP and CLI](../editor/mcp.md) for headless authoring and automation.
For an optimized build use `linux-release`. The `linux-sanitize` preset enables
AddressSanitizer and UndefinedBehaviorSanitizer for tests without the graphics backend.
## Optional Lua module
Engine development builds enable `FASET_ENABLE_LUA` by default. Lua 5.4.9 is compiled
from its checksum-pinned source archive; no system Lua installation is required.
Pass `-DFASET_ENABLE_LUA=OFF` to omit the VM and bindings. The Editor's project
build/export service selects this flag from `scripting.lua.scripts` in
`project.faset.json`, so C++-only games do not link Lua.
See the [Lua guide](../scripting/lua.md) for the manifest, a Lua-only project,
hot reload, and external-editor/LuaLS setup. Headless CPU checks can be run with:
```sh
cmake --preset linux-debug -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF
cmake --build --preset linux-debug --parallel
ctest --preset linux-debug
```
These checks do not verify the graphical Player or renderer.
## Dependencies and offline builds
Dependency source URLs, commits, and archive SHA-256 values are stored in
+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
Linux GPU and software Vulkan on Windows. See the
[acceptance dossier](https://github.com/emil28092005/Faset_Engine/blob/main/docs/validation/mvp-acceptance.md)
for exact source revisions and coverage limits. Lua and advanced graphics remain
later milestones.
for exact source revisions and coverage limits. The optional Lua module is
documented separately; those historical acceptance results do not certify later
changes. Advanced graphics remain later milestones.
Start with [how C++ gameplay works](scripting/index.md), then read
Start with [how gameplay works](scripting/index.md), then read
[frame and physics updates](scripting/lifecycle.md). See
[Build from source](getting-started/build.md) for the toolchain and build commands.
@@ -30,7 +31,9 @@ The manual grows alongside tested engine capabilities, in this order:
5. Work with scene templates, assets, and references.
6. Import from Blender and export a standalone game.
Lua is planned after the C++ foundation. It is not a current scripting option.
For interpreted gameplay, follow [Lua gameplay](scripting/lua.md): declare component
fields, write callbacks, and reload scripts during development Play. The Lua-only
example in `examples/lua` uses the same physics and scene model as the C++ tutorials.
## Preview this manual
+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
+236
View File
@@ -0,0 +1,236 @@
# Lua gameplay
Faset embeds **Lua 5.4.9** as an optional gameplay module. Lua and compiled C++
behaviors share the same runtime lifecycle, typed entity operations, scene components,
and Inspector metadata. The Editor does not run gameplay code in its own process.
There is no built-in script editor: edit `.lua` files in Zed or another external editor.
## Enable Lua in a project
Add explicit entry scripts to `project.faset.json`:
```json
"scripting": {
"lua": {
"scripts": ["Scripts/player.lua", "Scripts/beacon.lua"]
}
}
```
This is a manifest fragment, not a complete project file. Each entry must return one
`faset.behavior` table with a unique custom TypeId. All sources live beneath `Scripts`
and are captured as an immutable build/export snapshot. Paths must be project-relative;
symlinks and paths outside `Scripts` are rejected. Auxiliary modules do not need to
appear in the entry list.
A Lua-only project can omit both `Scripts/Gameplay.cpp` and `Scripts/Gameplay.hpp`.
A mixed project keeps that pair and adds the Lua declaration. TypeIds must be unique
across both languages, and the `faset.*` namespace is reserved for native components.
The engine developer option `FASET_ENABLE_LUA` defaults to `ON`. Project builds select
it from the manifest, so a C++-only game does not link the Lua VM. Lua is pinned and
built from source; no system Lua installation is required.
The complete `examples/lua` project includes a playable
2D controller, a non-physical animated beacon, and a shared module. Its scripts are
also loaded by the Lua contract test.
## Write a behavior
```lua
local Player = faset.behavior {
id = "game.player",
version = 1,
name = "Player",
fields = {
speed = {
name = "Move speed", type = "number", default = 5,
min = 0, max = 30, units = "m/s"
}
}
}
function Player:on_start()
self.state.elapsed = 0
end
function Player:fixed_update(delta)
self.state.elapsed = self.state.elapsed + delta
local velocity = self.entity:velocity()
velocity.x = faset.input().horizontal * self.fields.speed
self.entity:set_velocity(velocity)
end
return Player
```
Attach a component with `type: "game.player"`, `version: 1`, and the desired field
overrides to an entity with a 2D or 3D rigid body. Refresh schemas to expose the
behavior in **Add Component** and its `speed` field in the Inspector. Saved scenes
store the stable TypeId and data, not an instance of a Lua object.
Each entity/component gets its own instance:
- `self.entity`: an opaque runtime handle, checked on every call.
- `self.fields`: a configuration copy, combining schema defaults and scene overrides.
- `self.state`: a fresh mutable table for counters, timers, and retained handles.
Changing either table does not modify the saved scene or create an Undo operation.
Module-local variables are shared by instances of that module; put per-entity state
in `self.state`. Lua tables returned by getters are copies, not native pointers.
## Lifecycle
Use colon definitions so Lua supplies `self`:
| Callback | When it runs |
|---|---|
| `on_start()` | Once after the instance and initial scene objects exist |
| `fixed_update(delta)` | Before each fixed physics step; delta is seconds |
| `on_collision(event)` | After physics, for contact begin/end |
| `update(delta)` | Once per rendered frame after fixed steps |
| `late_update(delta)` | After presentation interpolation |
| `on_destroy()` | Before component/entity removal, while the handle is still valid |
Omit unused callbacks. The same [timing rules](lifecycle.md) as C++ apply, including
input edges, fixed-tick catch-up, deferred structural changes, pause and single-step.
Do not multiply velocity by delta; multiply a manually calculated displacement.
An error is reported with source location/traceback and disables the offending
instance for that generation and releases its instance state. Other instances can continue.
The VM quota is shared: allocations retained by module-level variables can still
affect other behaviors. A restart/reload creates
fresh instances; disabled instances are not automatically retried every frame.
Changes already made or queued by a failing callback are not rolled back.
## Runtime API
`faset.find("scene-id")` returns an entity handle or `nil`. Handles support equality
and `:valid()`. Retained handles become invalid after destruction or scene restart;
calling other methods on a stale handle reports an error.
| Entity method | Contract |
|---|---|
| `:transform()` / `:presentation()` | Copy of simulation/display transform |
| `:set_transform(pose)` | Non-physical objects only |
| `:set_presentation(pose)` | Display-only write during `late_update` |
| `:teleport(pose)` | Explicit discontinuous pose change; preserves velocity |
| `:fields(type_id)` | Copy of the named component's stored fields |
| `:velocity()` / `:set_velocity(v)` | Linear velocity, rigid bodies only |
| `:apply_impulse(v)` | Impulse at the rigid body's centre |
| `:is_grounded()` | Support from completed native physics contacts |
| `:destroy()` | Queue entity/descendant removal |
| `:add_component(record)` | Queue a complete component record |
| `:remove_component(type_id)` | Queue component removal |
Typed vectors are `{x = 1, y = 2, z = 0}`. Transforms contain `position`, `rotation`,
and `scale`, each a named vector. Positions use metres; rotations use XYZ Euler
radians. In contrast, **scene/component JSON arrays** are represented as ordinary
1-based Lua arrays, such as `fields.position = {1, 2, 0}`. Use `faset.null` to retain
an explicit JSON null; Lua `nil` removes a table key.
An empty Lua table converts to a JSON object; an empty schema default with
`type = "array"` is normalized to an empty JSON array.
`faset.input()` returns `horizontal`, `vertical`, `jump_pressed`, and
`interact_pressed`. Player mappings are A/D or arrows, W/S or arrows, Space, and E.
`faset.log(...)` sends a bounded message to Player logs and the Editor Console.
Collision events contain `first`, `second`, `other` (the opposite entity), and `began`.
They are copied for Lua, but retained entity handles still need validity checks.
`faset.spawn(record)` queues a full scene entity record. It returns **no handle**:
use `faset.find(id)` after the next fixed-tick barrier. Spawn, destroy, add and remove
operations follow FIFO order and do not mutate Editor documents. For example:
```lua
faset.spawn {
id = "effect-1", name = "Effect", parent = faset.null,
components = {
{
id = "effect-transform", type = "faset.transform", version = 1,
fields = { position = {0, 2, 0} }
}
}
}
```
## Shared modules and sandbox
`require("util.motion")` resolves `Scripts/util/motion.lua`, then
`Scripts/util/motion/init.lua`, inside the captured source snapshot. A module is
evaluated once and its result cached within the VM. Missing modules, cycles, and
path-like names are errors. There is no native module search, package installation,
network access, or arbitrary file access.
Basic Lua operations and the `math`, `string`, `table`, and `utf8` libraries are
available. `io`, `os`, `debug`, dynamic `load`, `loadfile`, `dofile`, `pcall`, `xpcall`,
`setmetatable`, `collectgarbage`, `string.dump`, and coroutines are not exposed.
Engine-owned metatables are locked; arbitrary finalizers cannot run during shutdown.
The restricted API intentionally prevents scripts
from catching execution-limit errors and continuing indefinitely.
The VM has memory and instruction budgets (`LuaLimits`, default 16 MiB and one
million instructions per protected entry/callback). These are gameplay reliability
limits, not a promise that executing untrusted code is equivalent to OS isolation.
JSON conversion also limits nesting, node count and expanded string/key bytes
(16 MiB), including repeated references to the same Lua string. Structural commands
are limited to 1,024 operations and 16 MiB of marshaled payload per callback.
The Player already runs separately from the Editor; only trusted local game projects
should be opened and built. C++ gameplay is native code and is not sandboxed.
Schema extraction evaluates entry scripts in the bounded VM but does not create a
world or invoke lifecycle callbacks. Keep top-level code declarative: calling runtime
operations there is an error. Metadata supports the same fields, constraints and
declarative [migration rules](api.md#editor-data-migrations) as C++ schemas. Runtime
loading does not migrate saved data automatically.
## Edit, reload, and export
The Editor command palette exposes:
| Command | Purpose |
|---|---|
| `faset_lua_refresh` | Build if needed, extract schemas, refresh Inspector metadata |
| `faset_lua_reload` | Request a Lua reload in a development Player |
| `faset_lua_setup` | Install Faset LuaLS declarations/configuration |
| `faset_script_open` | Open a script in an external editor |
The external-editor default is `zed`. Set `editor.script_editor` in
`project.faset.json` to an argument array such as `["code", "--goto", "{file}"]`,
or pass an `editor` argument array to `faset_script_open`. Exact `{file}` and
`{project}` arguments are substituted; a missing file argument is appended. The
command launches the executable directly, without a shell. The Assets panel lists
Lua sources under `Scripts` and provides **Open Script**.
Development Play watches Lua changes. The Player's `--watch-lua` option enables this
for direct development runs. A candidate source generation is loaded and validated
before replacement; an invalid candidate leaves the preceding generation running.
Successful reload **restarts the scene**, invalidates old handles, and resets all
script state. This is not state-preserving hot swapping. C++ source changes still
require a rebuild and a new Player process.
Export captures the declared entry list and Lua modules with the game. The exported
Player runs without the Editor or a separate Lua installation; development watching
is not enabled by ordinary exported-game launch. Exported Lua remains readable source,
not encrypted code. A C++-only project continues to export without the Lua VM.
## Zed and LuaLS
Run `faset_lua_setup`. It copies annotation-only declarations to
`.faset/lua/faset.lua` and creates `.luarc.json` **only if it does not already exist**.
For an existing LuaLS configuration, merge these settings yourself:
```json
{
"runtime.version": "Lua 5.4",
"runtime.path": ["Scripts/?.lua", "Scripts/?/init.lua"],
"workspace.library": [".faset/lua"],
"workspace.checkThirdParty": false,
"diagnostics.globals": ["faset"]
}
```
Use an editor with LuaLS integration and open the project directory. Annotations
describe the Faset API for completion and diagnostics; they are not runtime code and
must not be `require`d. The engine does not embed an LSP client, code editor, or a
breakpoint debugger. Player logs/tracebacks are the first debugging surface.
+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.