Author SHA1 Message Date
Emil 62059e154d Compare source-open Unicode argv against canonical paths
Native and manual checks / native (ubuntu-24.04) (push) Failing after 30s
Native and manual checks / manual (push) Successful in 28s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
2026-09-24 02:46:07 +03:00
Emil a68fb8e7fd Collect repeatable P1 workflow evidence 2026-09-24 02:44:51 +03:00
Emil 93946c1d24 Refresh Lua Release evidence after verifier review
Native and manual checks / native (ubuntu-24.04) (push) Failing after 34s
Native and manual checks / manual (push) Successful in 29s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
2026-09-24 02:35:14 +03:00
Emil 7c777eba7c Harden Lua export verification against stale or incomplete evidence 2026-09-24 02:35:14 +03:00
Emil af836785d3 Qualify Windows UTF-8 test entry point 2026-09-24 02:34:12 +03:00
Emil ba8bf45dbc Add repeatable synthetic Editor input latency probe 2026-09-24 02:30:41 +03:00
Emil 9e921a5b3b Record relocated Lua Release execution on Linux
Native and manual checks / native (ubuntu-24.04) (push) Failing after 33s
Native and manual checks / manual (push) Successful in 29s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
2026-09-24 02:26:10 +03:00
Emil 6446f8917e Verify relocated Lua Release games with checked rendering 2026-09-24 02:26:10 +03:00
Emil 45c5d2d188 Show and configure safe Editor autosave 2026-09-24 02:25:55 +03:00
Emil 77c145fa02 Fix Windows diagnostic paths and schema fixture timeout 2026-09-24 02:19:09 +03:00
Emil 4a434f7855 Autosave named scenes with revision and disk conflict safety 2026-09-24 02:11:26 +03:00
Emil d3217838cb Show source-linked diagnostics and expandable build logs 2026-09-24 02:01:47 +03:00
Emil cf5b702233 Record Linux starter project validation
Native and manual checks / native (ubuntu-24.04) (push) Failing after 36s
Native and manual checks / manual (push) Successful in 29s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
2026-09-24 01:59:22 +03:00
Emil 6459d69cc7 Offer runnable C++ and Lua project starters 2026-09-24 01:59:17 +03:00
Emil d4c1f0e86b Open project C++ and Lua sources at diagnostic locations 2026-09-24 01:58:59 +03:00
Emil abbb71d0c1 Protect native cache paths and snapshot gameplay sources 2026-09-24 01:55:50 +03:00
Emil 4ec5a3cd8a Expose structured gameplay build diagnostics 2026-09-24 01:46:38 +03:00
Emil 4a59ca1629 Reuse verified gameplay schema and build generations 2026-09-24 01:41:26 +03:00
Emil deb37c308b Capture complete gameplay and toolchain build inputs 2026-09-24 01:35:02 +03:00
57 changed files with 6521 additions and 228 deletions
+1
View File
@@ -96,3 +96,4 @@ jobs:
python-version: '3.12'
- run: python -m pip install -r docs/requirements.txt
- run: python -m mkdocs build --strict
- run: python tests/verify_playable_exports_test.py
+2 -2
View File
@@ -72,8 +72,8 @@ jobs:
run: ctest --preset windows-debug --timeout 180
- name: Real Release exports, 2D and 3D execution, incremental Debug rebuild
run: build/windows-debug/faset_build_service_tests.exe --integration .cache/windows-export-e2e
- name: Export and relocate both checked-in playable games
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports
- name: Export and relocate C++ and Lua playable games
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports --include-lua
- name: Preserve graphics and export evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+4 -1
View File
@@ -74,13 +74,16 @@ if(TARGET faset_assets AND TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_D
endif()
if(TARGET faset_editor_commands AND TARGET faset_build_service)
include(cmake/Plugins.cmake)
add_library(faset_editor_session STATIC src/editor/session.cpp)
add_library(faset_editor_session STATIC src/editor/session.cpp src/editor/autosave.cpp)
target_link_libraries(faset_editor_session PUBLIC faset_editor_commands faset_build_service faset_assets faset_editor_plugins)
if(BUILD_TESTING)
add_executable(faset_editor_session_tests tests/editor_session_tests.cpp)
target_link_libraries(faset_editor_session_tests PRIVATE faset_editor_session)
target_compile_definitions(faset_editor_session_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
add_test(NAME editor_session_settings COMMAND faset_editor_session_tests)
add_executable(faset_editor_autosave_tests tests/editor_autosave_tests.cpp)
target_link_libraries(faset_editor_autosave_tests PRIVATE faset_editor_session)
add_test(NAME editor_autosave COMMAND faset_editor_autosave_tests)
endif()
endif()
foreach(module UI EditorUI Editor Applications)
+50 -7
View File
@@ -4,6 +4,7 @@
#include <faset/editor/mcp.hpp>
#include <faset/editor/session.hpp>
#include <iostream>
#include <memory>
#include <thread>
#ifdef FASET_HAS_EDITOR_UI
#include <faset/editor/editor_ui.hpp>
@@ -40,7 +41,7 @@ void help() {
std::cout
<< "Faset Editor\n"
" faset_editor Open the project launcher\n"
" faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n"
" faset_editor --project PATH [--new NAME --dimension 2|3 [--language cpp|lua]] [--scene RELATIVE_PATH]\n"
" faset_editor --project PATH --mcp [--gui]\n"
" faset_editor --project PATH --command JSON [--wait]\n"
"Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n"
@@ -52,9 +53,10 @@ int editor_main(int argc, char** argv) {
using namespace faset::editor;
try {
std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture;
std::string new_name, command;
std::string new_name, command, language = "cpp";
int dimension = 3;
bool mcp = false, gui = true, explicit_gui = false, wait = false;
bool explicit_language = false;
std::uint64_t frames = 0;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
@@ -74,6 +76,10 @@ int editor_main(int argc, char** argv) {
new_name = value();
else if (arg == "--dimension")
dimension = std::stoi(value());
else if (arg == "--language") {
language = value();
explicit_language = true;
}
else if (arg == "--scene")
scene = path_from_utf8(value());
else if (arg == "--mcp")
@@ -100,30 +106,65 @@ int editor_main(int argc, char** argv) {
throw Error("cli.option", "Unknown option: " + arg);
}
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command");
require(!explicit_language || !new_name.empty(), "cli.language",
"--language requires --new NAME");
require(language == "cpp" || language == "lua", "cli.language",
"Language must be cpp or lua");
if (mcp && !explicit_gui)
gui = false;
require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project",
"Use --project PATH for command, MCP, or --new modes");
auto previous_project = project;
#ifdef FASET_HAS_EDITOR_UI
std::optional<ProjectSelection> retry_create;
std::string creation_error;
#endif
for (;;) {
if (project.empty()) {
#ifdef FASET_HAS_EDITOR_UI
const auto selection =
run_project_launcher(engine, previous_project, frames, capture);
run_project_launcher(engine, previous_project, frames, capture,
retry_create, creation_error);
retry_create.reset();
creation_error.clear();
if (!selection)
return 0;
project = selection->path;
new_name = selection->create ? selection->name : std::string();
dimension = selection->dimension;
language = selection->language;
explicit_language = selection->create;
#else
throw Error("editor.gui_unavailable",
"This build has no graphical project launcher; use --project PATH");
#endif
}
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
executable_directory(argv[0])});
if (!new_name.empty())
session.scaffold(new_name, dimension);
std::unique_ptr<Session> session_holder;
try {
session_holder = std::make_unique<Session>(SessionConfig{
std::filesystem::absolute(project), std::filesystem::absolute(engine),
executable_directory(argv[0])});
if (!new_name.empty()) {
if (explicit_language)
session_holder->scaffold(new_name, dimension, language);
else
session_holder->scaffold(new_name, dimension);
}
} catch (const std::exception& error) {
#ifdef FASET_HAS_EDITOR_UI
if (gui && !new_name.empty()) {
retry_create = ProjectSelection{std::filesystem::absolute(project),
new_name, dimension, true, language};
creation_error = error.what();
project.clear();
new_name.clear();
scene.clear();
continue;
}
#endif
throw;
}
Session& session = *session_holder;
const auto settings = session.project();
#ifdef FASET_HAS_EDITOR_UI
if (gui && std::filesystem::exists(project / "project.faset.json"))
@@ -167,6 +208,8 @@ int editor_main(int argc, char** argv) {
project.clear();
scene.clear();
new_name.clear();
explicit_language = false;
language = "cpp";
continue;
#else
throw Error(
+52 -10
View File
@@ -75,6 +75,22 @@ ProjectSelection open_project(const fs::path& path) {
throw std::runtime_error("Project name or scene type is invalid.");
return {path, std::move(name), dimension, false};
}
bool fresh_destination(const fs::path& path) {
if (!fs::exists(path))
return true;
if (!fs::is_directory(path) || fs::is_symlink(path))
return false;
for (const auto& entry : fs::directory_iterator(path)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
return false;
for (const auto& internal : fs::directory_iterator(entry.path()))
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
return false;
}
return true;
}
Json recent_records(const fs::path& file) {
try {
auto j = read_json(file);
@@ -124,9 +140,11 @@ struct ProjectLauncher::Impl {
std::optional<ProjectSelection> selected;
bool create = false, cancelled = false, browsing = false, browser_valid = false;
int dimension = 3;
std::string language = "cpp";
std::string error;
Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial,
const fs::path& recents)
const fs::path& recents, const std::optional<ProjectSelection>& retry,
std::string creation_error)
: renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
recents_file(recents.empty() ? recent_path() : recents) {
auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
@@ -147,13 +165,22 @@ struct ProjectLauncher::Impl {
ui.set_ime(
[this](bool enabled) { renderer.set_text_input(enabled); },
[this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); });
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
const auto start = retry ? retry->path
: initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
ui.update_text("launcher-path", path_to_utf8(start));
create = initial.empty();
create = retry ? retry->create : initial.empty();
if (retry) {
ui.update_text("launcher-name", retry->name);
dimension = retry->dimension;
language = retry->language;
error = std::move(creation_error);
}
ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); };
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; };
ui.find("launcher-cpp")->on_click = [this](Widget&) { language = "cpp"; };
ui.find("launcher-lua")->on_click = [this](Widget&) { language = "lua"; };
ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); };
ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; };
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); };
@@ -209,14 +236,14 @@ struct ProjectLauncher::Impl {
throw std::runtime_error("Enter a project name.");
if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos)
throw std::runtime_error("Use a short project name on one line.");
if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(path)))
if (!fresh_destination(path))
throw std::runtime_error("Create requires a new or empty directory.");
auto parent = path.parent_path();
while (!parent.empty() && !fs::exists(parent))
parent = parent.parent_path();
if (parent.empty() || !fs::is_directory(parent))
throw std::runtime_error("Project parent directory is unavailable.");
selected = ProjectSelection{path, name, dimension, true};
selected = ProjectSelection{path, name, dimension, true, language};
} else
selected = open_project(path);
error.clear();
@@ -351,14 +378,24 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->selected = create;
ui.find("launcher-2d")->selected = dimension == 2;
ui.find("launcher-3d")->selected = dimension == 3;
ui.find("launcher-cpp")->selected = language == "cpp";
ui.find("launcher-lua")->selected = language == "lua";
ui.find("launcher-2d")->appearance =
dimension == 2 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-3d")->appearance =
dimension == 3 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-cpp")->appearance =
language == "cpp" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-lua")->appearance =
language == "lua" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-language-hint")->text =
language == "cpp" ? "Compiled gameplay. Add Lua later if you need quick reloads."
: "Lua gameplay reloads during development without a C++ rebuild.";
for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label",
"launcher-dimensions", "launcher-name-group",
"launcher-name-space", "launcher-path-space",
"launcher-dimension-group"})
"launcher-dimension-group", "launcher-language-group",
"launcher-language-space"})
ui.find(id)->visible = create;
ui.find("launcher-heading")->text = create ? "Create project" : "Open project";
ui.find("launcher-submit")->text = create ? "Create project" : "Open project";
@@ -424,8 +461,11 @@ struct ProjectLauncher::Impl {
}
};
ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine,
const fs::path& initial, const fs::path& recents)
: impl_(std::make_unique<Impl>(r, engine, initial, recents)) {}
const fs::path& initial, const fs::path& recents,
const std::optional<ProjectSelection>& retry,
std::string creation_error)
: impl_(std::make_unique<Impl>(r, engine, initial, recents, retry,
std::move(creation_error))) {}
ProjectLauncher::~ProjectLauncher() = default;
void ProjectLauncher::frame(const std::vector<render::Event>& events) {
impl_->frame(events);
@@ -445,9 +485,11 @@ bool ProjectLauncher::cancelled() const {
std::optional<ProjectSelection> run_project_launcher(const fs::path& engine,
const fs::path& initial,
std::uint64_t max_frames,
const fs::path& capture) {
const fs::path& capture,
const std::optional<ProjectSelection>& retry,
std::string creation_error) {
render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true});
ProjectLauncher launcher(renderer, engine, initial);
ProjectLauncher launcher(renderer, engine, initial, {}, retry, std::move(creation_error));
std::uint64_t frames = 0;
while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() &&
(max_frames == 0 || frames < max_frames)) {
+9
View File
@@ -41,6 +41,15 @@
{"id": "launcher-3d", "kind": "button", "text": "3D", "layout": {"width": 116}}
]}
]},
{"id": "launcher-language-space", "kind": "row", "layout": {"height": 18}},
{"id": "launcher-language-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-language-label", "kind": "label", "text": "Gameplay language", "layout": {"height": 24}},
{"id": "launcher-languages", "kind": "row", "layout": {"height": 42, "gap": 4}, "children": [
{"id": "launcher-cpp", "kind": "button", "text": "C++", "layout": {"width": 116}},
{"id": "launcher-lua", "kind": "button", "text": "Lua", "layout": {"width": 116}}
]},
{"id": "launcher-language-hint", "kind": "label", "text": "Compiled gameplay. Add Lua later if you need quick reloads.", "font_size": 12, "layout": {"height": 30}}
]},
{"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "font_size": 13, "layout": {"height": 36}},
{"id": "launcher-actions-space", "kind": "row", "layout": {"height": 22}},
{"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}},
+19 -2
View File
@@ -1,8 +1,18 @@
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
${PROJECT_SOURCE_DIR}/src/editor/build_cache.cpp
${PROJECT_SOURCE_DIR}/src/editor/build_diagnostics.cpp)
target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_compile_features(faset_build_service PUBLIC cxx_std_20)
target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads)
if(BUILD_TESTING)
add_executable(faset_build_diagnostics_tests ${PROJECT_SOURCE_DIR}/tests/build_diagnostics_tests.cpp)
target_link_libraries(faset_build_diagnostics_tests PRIVATE faset_build_service)
add_test(NAME build_diagnostics COMMAND faset_build_diagnostics_tests)
add_executable(faset_build_cache_tests ${PROJECT_SOURCE_DIR}/tests/build_cache_tests.cpp)
target_link_libraries(faset_build_cache_tests PRIVATE faset_build_service)
target_compile_definitions(faset_build_cache_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
add_test(NAME build_cache COMMAND faset_build_cache_tests)
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_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
@@ -13,5 +23,12 @@ if(BUILD_TESTING)
target_link_libraries(faset_build_schema_tests PRIVATE faset_build_service faset_authoring faset_editor_commands)
add_dependencies(faset_build_schema_tests faset_build_schema_tool)
add_test(NAME build_schema_publication COMMAND faset_build_schema_tests $<TARGET_FILE:faset_build_schema_tool> ${PROJECT_SOURCE_DIR})
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60)
# This fixture performs many full cache invalidations and process launches.
# Windows runner startup and antivirus overhead exceed 60 seconds even when
# individual native fixture commands finish normally.
if(WIN32)
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 240)
else()
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60)
endif()
endif()
+3
View File
@@ -47,5 +47,8 @@ if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view)
set_tests_properties(editor_ui_gizmos PROPERTIES LABELS "gpu")
add_test(NAME editor_ui_authoring COMMAND faset_editor_ui_tests)
set_tests_properties(editor_ui_authoring PROPERTIES LABELS "gpu")
add_executable(faset_editor_ui_latency ${PROJECT_SOURCE_DIR}/tests/editor_ui_latency.cpp)
target_link_libraries(faset_editor_ui_latency PRIVATE faset_editor_ui)
target_compile_definitions(faset_editor_ui_latency PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
endif()
endif()
+23
View File
@@ -1,5 +1,28 @@
# Developer diagnostics
## Build errors and source navigation
Build and export jobs report compiler, clang-cl/MSVC, and Lua errors as structured
Console rows. Each row shows severity, message, and a one-based source location.
Choose **Open source** to open a file under the current project's `Scripts/`
directory at that line and column. Diagnostics from engine or external files remain
visible but have no source action. The **Jobs** tab shows whether a verified build
generation was reused, the elapsed phases, and an expandable raw process log;
**Copy full log** retains the complete available output for troubleshooting.
The same action is available over Editor MCP:
```json
{"command":"faset_source_open","arguments":{"path":"Scripts/Gameplay.cpp","line":17,"column":4}}
```
`faset_script_open` remains Lua-only. Both commands use the project's optional
`editor.script_editor` argv array, or Zed by default. The tokens `{file}`,
`{line}`, `{column}`, and `{project}` may appear within one argument, for
example `["zed", "{file}:{line}:{column}"]`. Faset passes each argument
literally to the editor process without shell expansion. An invalid editor
command produces a source-editor error without changing the build result.
The optional Dear ImGui overlay shows renderer counters inside the Editor. The Editor's normal interface remains the retained Faset UI. Enable the diagnostic build explicitly:
```sh
+14
View File
@@ -67,6 +67,20 @@ resume and single-step process controls; it cannot read or modify game entities.
scenes that have never been saved. Restoring an already open document requires its
current revision. Recovery refuses to overwrite an externally changed scene file.
Long-lived headless and graphical Editor sessions also poll the same autosave
controller. With `editor.autosave` enabled (the default), a named dirty scene is
saved after two seconds idle. `faset_autosave_status {}` reports `enabled` and each
open document's `state`, `revision`, `path`, and `error`. States include `saved`,
`pending`, `saving`, `conflict`, `failed`, `save_as_required`, and `disabled`.
An unnamed scene remains in recovery until you give it an explicit path. An
external disk edit produces `conflict` without overwriting that edit. For scripted
workflows, use `faset_document_save` with `expected_revision` when you need a
definite save boundary instead of waiting for idle autosave. If a conflict occurs,
save to a new project-relative path and compare the two files. Change the
preference through `faset_project_settings_get` and `faset_project_settings_set`
with the returned project revision and `{"editor":{"autosave":false}}`; the
new value applies to the current session and persists for the next Editor launch.
## Single-command CLI
The CLI is useful for scripts that do not need an MCP session:
+22 -6
View File
@@ -50,6 +50,19 @@ outside the Editor, ordinary Save reports a conflict instead of overwriting it.
Save your in-memory work to a new path and compare the two versions before replacing
anything.
Autosave is enabled by default. After a named scene has been idle for two seconds,
the Editor saves its current revision to that scene file. The status bar shows
**Pending autosave**, **Saving**, or **Saved**. An unnamed scene shows **Save As
required** until you choose its path. The status bar's **Save As...** action opens
an editable, new project-relative path; it never overwrites the conflicting file.
If the disk file changes outside Faset, autosave stops for that revision and shows
**Save conflict**. Use **Save As...** to keep both versions, then compare or reload
the original. **Save failed** preserves the unsaved scene in the recovery journal;
correct the write error or use Save As. Autosave does not clear Undo history or
replace the [recovery journal](#recover-unsaved-work). Recovery still protects
unnamed scenes and edits before the idle deadline.
## Navigate and transform
- **Right drag:** orbit the 3D view; pan in a 2D scene.
@@ -119,13 +132,16 @@ for the C++ iteration loop.
## Project settings
Open **File → Project settings** to change the project name, initial **2D / 3D** type,
and start-scene path. Save a scene first, then choose it from the saved-scene list or
enter its project-relative path. Choose **Save project** to update
`project.faset.json` explicitly. A missing start scene is rejected and the dialog
stays open.
start-scene path, or **Autosave named scenes** preference. Save a scene first, then
choose it from the saved-scene list or enter its project-relative path. Choose
**Save project** to update `project.faset.json` explicitly. A missing start scene is
rejected and the dialog stays open. The Autosave preference applies immediately;
when disabled, the status bar says **Autosave off** and you can still use Ctrl+S or
Save As. The project file stores it as `editor.autosave`; omitting it means enabled.
These settings are used when the project opens again; they do not convert the active
scene, switch its dimension, restart the Player, or add a scene Undo entry.
The project name, default dimension, and start scene are used when the project opens
again; they do not convert the active scene, switch its dimension, restart the Player,
or add a scene Undo entry.
**Cancel** or **Escape** discards this form's draft. If another writer changes the
project settings while the dialog is open, Save reports a revision conflict and
keeps your draft. **Reload saved** deliberately discards it and reloads the latest
+13 -3
View File
@@ -31,17 +31,27 @@ cmake --build --preset linux-debug --parallel
ctest --preset linux-debug
```
Create a project and open the native Editor:
Create a runnable C++ or Lua starter and open the native Editor:
```sh
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3 --language cpp
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
```
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. Its Create view offers C++ 2D, C++ 3D, Lua 2D, and Lua 3D.
Each explicit starter includes `Scenes/main.scene.json` with a player, ground,
and a behavior bound to `Scripts/Gameplay.cpp` or `Scripts/main.lua`. 3D starters
also include a camera and light. Existing files in a destination directory are
never replaced by starter creation. The older `--new NAME --dimension 2|3`
command without `--language` retains its original scene-free C++ scaffold.
Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
The Player runs separately. Stop it before changing and rebuilding C++ gameplay.
Lua starters declare their entry script in `project.faset.json`, install LuaLS
annotations/configuration, and need no `Gameplay.cpp`. Use **Refresh Lua** after
editing a Lua behavior declaration; development **Reload Lua** can apply a valid
script edit to a running Player without recompiling C++.
See [MCP and CLI](../editor/mcp.md) for headless authoring and automation.
For an optimized build use `linux-release`. The `linux-sanitize` preset enables
+17
View File
@@ -5,6 +5,23 @@ behaviors share the same runtime lifecycle, typed entity operations, scene compo
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.
## Start with a runnable Lua project
Choose **Create project → Lua → 2D or 3D** in the native launcher, or run:
```sh
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
```
The starter creates `Scripts/main.lua`, a scene with a player and ground, and a
manifest entry for that script. The player moves horizontally and jumps with the
default input actions. The 3D choice also provides a camera and directional light.
Open `Scripts/main.lua` in your external editor and change `speed` or
`fixed_update`. **Refresh Lua** updates declared Inspector fields; **Reload Lua**
updates a development Player after a valid edit without rebuilding native gameplay.
The starter installs LuaLS declarations and `.luarc.json`; reopening it in Zed or
another LuaLS-enabled editor provides completions for the Faset API.
## Enable Lua in a project
Add explicit entry scripts to `project.faset.json`:
+18 -1
View File
@@ -40,7 +40,7 @@ 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
## Not verified in the September 18 run
- Windows compilation or execution of the new module.
- Graphical/window interaction and real-time Lua reload in a running rendered game.
@@ -53,3 +53,20 @@ run `ctest --test-dir <build> --output-on-failure -R '^lua_'`.
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.
## September 24 Release follow-up
The [Release execution record](lua-release-2026-09-24/README.md) closes the
Linux offscreen export gap above. At source commit `4331857`, a Release Editor
exported the checked-in Lua-only sample into a disposable Unicode-path project.
The package was relocated, the source project path was hidden, and its Player
validated and rendered 120 frames on an NVIDIA GeForce RTX 2080 Ti with driver
595.84 and Khronos validation enabled; zero validation errors were reported.
The package manifest has `lua_enabled: true`, declared Lua source and its license,
and no project C++ gameplay source. The five focused Release Lua/schema tests
passed. This verifies an offscreen run on Linux; a separate physical Windows GPU
and manual window interaction have not been tested by this record. Windows
SwiftShader execution is configured in CI and needs a passing run before it is
claimed as covered.
![Relocated Lua sample after 120 frames](lua-release-2026-09-24/lua.png)
@@ -0,0 +1,52 @@
# Lua-only Release game: relocated execution
This is a local Linux result for source revision
`433185717e1f324acb6eaf186818589d11697453` on 2026-09-24. The
[machine-readable report](report.json) records the exact source input hashes,
Release package, Editor binary hash, command arguments and elapsed times. The
Editor itself was built with the `linux-release` preset. The exported Player
and package also identify their configuration as Release.
| Check | Observed result |
|---|---|
| Disposable source | `examples/lua` copied to a Unicode-path temporary project outside the engine tree |
| Package | `lua_enabled: true`; 32 hash-checked files, including Lua gameplay source and `Notices/lua/LICENSE.txt`; no project `Gameplay.cpp` or `.hpp` |
| Relocation | Generation copied outside the project/engine; original project path hidden before Player launch |
| CPU validation | Packaged Player `--validate` succeeded from an unrelated empty working directory |
| GPU execution | 120/120 headless 1280×720 frames, 0 validation errors |
| Device | NVIDIA GeForce RTX 2080 Ti; proprietary driver 595.84, Vulkan 1.4.329; selected-device details in the report and [Vulkan probe](vulkaninfo-summary.txt) |
| Image | Four expected scene colors, including cyan player and gold collectible; raw [PPM](lua.ppm) SHA-256 `e2f4c3b84de75dadd2e31d388d0ba51abc1861f1ec76eb7ba51a4a2dcac720a7` |
| Focused Release CTest | 5/5: `lua_contracts`, `lua_safety_contracts`, `lua_cli_contracts`, `lua_player_reload`, `build_schema_publication` |
The [manifest](lua-manifest.json) binds the package file checksums. The
[Player profile](lua-profile.json) contains all 120 per-frame samples and
resource/timing definitions; the [validate](lua-validate.json),
[render](lua-run.json) and [export](lua-export.json) logs retain raw process
results. The [PNG preview](lua.png) is a lossless conversion of the checked
PPM.
The verifier also confirmed a clean Git source tree, rejected undeclared
package files and unexpected C++ script files, classified the selected device
as physical, and found the sample Lua `on_start` log in the relocated run.
To repeat this check from a built checkout, choose a **new, empty** output
directory:
```sh
cmake --preset linux-release
cmake --build --preset linux-release --target faset_editor --parallel 4
python3 tools/verify_playable_exports.py --editor build/linux-release/faset_editor \
--output .cache/lua-release-check --only-lua
cmake --build --preset linux-release --target faset_player faset_schema_exporter \
faset_lua_tests faset_lua_safety_tests faset_build_schema_tests --parallel 4
ctest --test-dir build/linux-release --no-tests=error --output-on-failure \
-R '^(lua_contracts|lua_safety_contracts|lua_cli_contracts|lua_player_reload|build_schema_publication)$'
```
The render used the Player's synthetic fixed timestep and offscreen Vulkan
presentation. It does not establish manual window interaction or physical
Windows GPU compatibility. The Windows graphics workflow now includes this Lua
package beside both C++ examples under a pinned SwiftShader Vulkan driver; its
result must be recorded separately after CI completes.
![Lua game rendered by the relocated Release Player](lua.png)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,220 @@
{
"asset_generations": {},
"build_fingerprint": "b745490f85f05987d849f6bb6f4d06953883f4b8e10c5551fadf164c7eec5857",
"configuration": "Release",
"executable": "faset_player",
"files": [
{
"path": "README.txt",
"sha256": "82bf72bc542643f6c4090e0a5de69b0014ca3fa17c6f9dfb12cc41e44c8b8db6",
"size": 262
},
{
"path": "Notices/Faset-NOTICE.txt",
"sha256": "30c2e7e80f5153b31ab57675e07246aef9165965d64e36022b426a26449ece3f",
"size": 204
},
{
"path": "Notices/dependencies.json",
"sha256": "262e8f63cf21c516829b1fedb0900ac90e76d62aa8f78d9ad02d8f24e0359e74",
"size": 2468
},
{
"path": "Notices/lua/LICENSE.txt",
"sha256": "e42d195c77d3ad7255ee9c874772d3c53998e7f60f9acf453d4630fa161e0bfe",
"size": 1126
},
{
"path": "Notices/stb/LICENSE",
"sha256": "bebfe904b14301657e4e5d655c811d51fd31b97c455b9cc2d8600d6bac6cff63",
"size": 2510
},
{
"path": "Notices/json/LICENSE.MIT",
"sha256": "46a65cffd1ea955132d95a8dd921640714a8d6b537d2e4e482d31145ae95b603",
"size": 1076
},
{
"path": "Notices/box3d/LICENSE",
"sha256": "da5e31a26bf3cfd5ba5c96d6823e480128c81e76c107ef9d3ee5d94789184b90",
"size": 1067
},
{
"path": "Notices/box2d/LICENSE",
"sha256": "68a3e676d7e94093b102d5cba0d4e04af812040d6f230c3db67a6664574e43d2",
"size": 1067
},
{
"path": "Notices/entt/LICENSE",
"sha256": "0785027ce472d7c61f05fba664a1d3ba6639c1593ced351ad9e4bed868765d99",
"size": 1097
},
{
"path": "Notices/sdl3/LICENSE.txt",
"sha256": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
"size": 884
},
{
"path": "shaders/gpuPostCullMain.reflection.json",
"sha256": "eed140bbe09c7d14935b688c770261ac0e81677c0974aa811c88ed67ea779b95",
"size": 3591
},
{
"path": "shaders/gpuHzbMain.reflection.json",
"sha256": "589818f670881c64f3aba726ee3bc82fe4b9d835a347f99134e0e157dc4a112e",
"size": 2050
},
{
"path": "shaders/gpuCullMain.reflection.json",
"sha256": "996fe5c7e31c6b8f316d8f290b3be0ae7063eb46c500a661e8715e9b822a2598",
"size": 3587
},
{
"path": "shaders/gpuShadowMain.reflection.json",
"sha256": "9a03a2b692d2b0c758f63fb27760352cfef20a068372ebc137f1ca1797dcd3c2",
"size": 2755
},
{
"path": "shaders/gpuVertexMain.reflection.json",
"sha256": "9f43472c267dbb6a2ddc69e252f7f00c5ca46f376d3c7c27165c8d12a175179d",
"size": 3098
},
{
"path": "shaders/gpuPostCullMain.spv",
"sha256": "62cb72e356346e1511c6d640a3cecb4a6e5283795338299b357edae5f7f52be4",
"size": 12460
},
{
"path": "shaders/gpuHzbMain.spv",
"sha256": "08682362ee2120ed19d260e613285071747c3e436bb09bb6238e46f9cedc2308",
"size": 4140
},
{
"path": "shaders/gpuCullMain.spv",
"sha256": "7691360ec55f41d40d43652f506b051cef8b3a3ddcdcc7db61a7d4d97f9e87d8",
"size": 16180
},
{
"path": "shaders/gpuShadowMain.spv",
"sha256": "ffcdb91e70c41ce9452094865546a009b9203140cf8eeb3ad1b4e5868d82b815",
"size": 2404
},
{
"path": "shaders/gpuVertexMain.spv",
"sha256": "4647ef4fe1a68ab623ee9c19dac56631907f825cba0becd30869c5221c2a0385",
"size": 4928
},
{
"path": "shaders/shadowMain.reflection.json",
"sha256": "e3fd0889cd2f569d410f5d7558a4091d9f57f247a1dcf1788462f67525340819",
"size": 2645
},
{
"path": "shaders/fragmentMain.reflection.json",
"sha256": "72ebf8d32452830650416dc4717d89a4324e97ece7028dfb97c66803c8dce71f",
"size": 2649
},
{
"path": "shaders/vertexMain.reflection.json",
"sha256": "c584f3b957ebf194a292451362a98590f85ff3cfbfd8f917b1c1265039381777",
"size": 2639
},
{
"path": "shaders/shadowMain.spv",
"sha256": "7643b4d688492b5ee923b9606f0b0e70343ca05fa67673838206119a6f86d8d8",
"size": 784
},
{
"path": "shaders/fragmentMain.spv",
"sha256": "f5c4f289917ecab5d00d543c053aac1d10e6cca311fd172daf06d315160a3505",
"size": 7436
},
{
"path": "shaders/vertexMain.spv",
"sha256": "1ad2631c35d654f48166321ae43d4165043e1b919b14f1dc61caf97b1ada0898",
"size": 1224
},
{
"path": "faset_player",
"sha256": "99d7b8286a9151ec4eff9a9899e862d987048b031e1e40ca1938646afbde37bd",
"size": 6329192
},
{
"path": "project.faset.json",
"sha256": "e07b12047f8141c3c4cc8f0e7d0905a5c635375e4494755e34191db0859ab86f",
"size": 175
},
{
"path": "Scripts/util/motion.lua",
"sha256": "bad3dcdc1081fd1dec589c12dad8dc175b739d77ea52b957ead9ecb4b7cb88a3",
"size": 150
},
{
"path": "Scripts/player.lua",
"sha256": "1c51810574a3413c46a7cbd0ea2e84a9b802da1206a2b72bb78f6cc951367a11",
"size": 1376
},
{
"path": "Scripts/beacon.lua",
"sha256": "44d932ebedf66cadb9620f2d926177be2f8d43d71d56aa83611de686af026231",
"size": 820
},
{
"path": "scene.fscene",
"sha256": "641ba14a1f9c3966b9ec797814fdfa54f954dc5e0d53ab00c645598edef61baf",
"size": 1681
}
],
"format": "faset.export",
"generation": "aa31bf16-984e-4854-b46f-15655bd6cc67",
"lua_enabled": true,
"lua_fingerprint": "a9261789bb1e0ca38bd40b08b0454165cd51454cba3ad4725dd357812ced6e35",
"platform": "linux",
"prerequisites": [
"Linux x86_64",
"Vulkan 1.3 driver",
"Compatible glibc and libstdc++ runtime"
],
"renderer_profile": {
"api": "Vulkan 1.3",
"materials": [
"base-color factor and texture",
"metallic and roughness factors"
],
"required_features": [
"dynamicRendering",
"synchronization2"
],
"shadow_map": {
"resolution": 1024,
"world_extent": 40
},
"texture_sampling": "linear clamp, one mip level",
"unsupported_material_features": [
"normal maps",
"metallic-roughness maps",
"emissive and occlusion maps",
"alpha mode selection",
"unlit mode",
"per-material face culling"
]
},
"scene_hash": "143724a468d711cbdf9a9700310d7b0acef003ce1b21422ca5204e0e565fa02c",
"simulation": {
"fixed_delta": 0.016666666666666666,
"gravity": [
0,
-9.81,
0
],
"max_catch_up_ticks": 4,
"physics_substeps": 4
},
"units": {
"angle": "radian",
"coordinates": "right-handed Y-up",
"distance": "metre"
},
"validation_log": "{\"dimension\":2,\"validated\":true}\n",
"version": 1
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
{
"arguments": [
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/faset_player",
"--headless",
"--frames",
"120",
"--capture",
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/verification.ppm",
"--profile",
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/profile.json"
],
"cwd": "/tmp/faset-playable-exports-8wzkrc_u/empty-working-directory",
"seconds": 0.6288121230027173,
"exit_code": 0,
"timed_out": false,
"stdout": "{\"device\":\"NVIDIA GeForce RTX 2080 Ti\",\"dimension\":2,\"effective_visibility_mode\":\"direct\",\"frames\":120,\"gpu_visibility_active\":false,\"ticks\":120,\"validation_errors\":0,\"visibility_mode\":\"direct\"}\n",
"stderr": "Lua player ready: A/D move, Space jump, E reset\n"
}
@@ -0,0 +1,12 @@
{
"arguments": [
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/faset_player",
"--validate"
],
"cwd": "/tmp/faset-playable-exports-8wzkrc_u/empty-working-directory",
"seconds": 0.007359832990914583,
"exit_code": 0,
"timed_out": false,
"stdout": "{\"dimension\":2,\"validated\":true}\n",
"stderr": ""
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,134 @@
{
"format": "faset.playable-export-verification",
"version": 1,
"started_utc": "2026-09-23T23:30:12.719805+00:00",
"platform": "linux",
"engine": "/home/emil/Desktop/.worktrees/Faset_Engine-p1-lua-export",
"editor": "/home/emil/Desktop/.worktrees/Faset_Engine-p1-lua-export/build/linux-release/faset_editor",
"standalone_root": "/tmp/faset-playable-exports-8wzkrc_u",
"engine_revision": "433185717e1f324acb6eaf186818589d11697453",
"engine_dirty": false,
"engine_git_status": [],
"engine_diff_sha256": null,
"editor_sha256": "03ac75942f5f1d29e9d6fedb21ecffc6c74f4299401dbe84f01ab1b4e9c176a0",
"project_workspace": "/tmp/faset-playable-projects-cuhq4iwr",
"vulkaninfo_available": true,
"driver_override": null,
"frames_per_game": 120,
"status": "passed",
"projects": [
{
"name": "lua",
"dimension": 2,
"language": "lua",
"source_inputs": [
{
"path": "README.md",
"sha256": "8392da3c18916400ea628daed094c77c390c553cbdcf822da227bdcd3d86380c"
},
{
"path": "Scenes/main.scene.json",
"sha256": "f1ab54150a58b289c47bcc34566ad7ee3025ad931db81e7f5dce70d9fbcac8ab"
},
{
"path": "Scripts/beacon.lua",
"sha256": "44d932ebedf66cadb9620f2d926177be2f8d43d71d56aa83611de686af026231"
},
{
"path": "Scripts/player.lua",
"sha256": "1c51810574a3413c46a7cbd0ea2e84a9b802da1206a2b72bb78f6cc951367a11"
},
{
"path": "Scripts/util/motion.lua",
"sha256": "bad3dcdc1081fd1dec589c12dad8dc175b739d77ea52b957ead9ecb4b7cb88a3"
},
{
"path": "project.faset.json",
"sha256": "7e4c0781c1837208b4499a9991ffb702143edf1f933ae9c7e3d2543fd2577747"
}
],
"generation": "aa31bf16-984e-4854-b46f-15655bd6cc67",
"configuration": "Release",
"standalone_directory": "/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua",
"executable": "faset_player",
"package_file_count": 32,
"asset_generations": {},
"device": "NVIDIA GeForce RTX 2080 Ti",
"device_class": "physical",
"driver": {
"apiVersion": "1.4.329",
"driverVersion": "595.84.0.0",
"deviceName": "NVIDIA GeForce RTX 2080 Ti",
"driverID": "DRIVER_ID_NVIDIA_PROPRIETARY",
"driverName": "NVIDIA",
"driverInfo": "595.84"
},
"validation_enabled": true,
"validation_errors": 0,
"completed_frames": 120,
"summary_ms": {
"gpu": {
"max": 0.584992,
"min": 0.551712,
"p50": 0.5608,
"p95": 0.574592,
"samples": 120
},
"render_call": {
"max": 4.928888,
"min": 1.544123,
"p50": 1.919881,
"p95": 2.423702,
"samples": 120
},
"renderer_cpu": {
"max": 4.923196,
"min": 1.540666,
"p50": 1.915753,
"p95": 2.420426,
"samples": 120
},
"renderer_readback_cpu": {
"max": 2.837363,
"min": 0.395756,
"p50": 0.504853,
"p95": 0.641169,
"samples": 120
},
"simulation": {
"max": 0.210056,
"min": 0.084289,
"p50": 0.122261,
"p95": 0.171062,
"samples": 120
},
"snapshot": {
"max": 0.254931,
"min": 0.046518,
"p50": 0.065754,
"p95": 0.087906,
"samples": 120
},
"wall": {
"max": 5.322149,
"min": 1.702973,
"p50": 2.142903,
"p95": 2.634219,
"samples": 120
}
},
"capture": {
"width": 1280,
"height": 720,
"sampled_colors": 4,
"sha256": "e2f4c3b84de75dadd2e31d388d0ba51abc1861f1ec76eb7ba51a4a2dcac720a7"
},
"source_project_paths_unavailable": true,
"status": "passed"
}
],
"requested_projects": [
"lua"
],
"finished_utc": "2026-09-23T23:32:18.151669+00:00"
}
@@ -0,0 +1,82 @@
==========
VULKANINFO
==========
Vulkan Instance Version: 1.4.341
Instance Extensions: count = 27
-------------------------------
VK_EXT_acquire_drm_display : extension revision 1
VK_EXT_acquire_xlib_display : extension revision 1
VK_EXT_debug_report : extension revision 10
VK_EXT_debug_utils : extension revision 2
VK_EXT_direct_mode_display : extension revision 1
VK_EXT_display_surface_counter : extension revision 1
VK_EXT_headless_surface : extension revision 1
VK_EXT_layer_settings : extension revision 2
VK_EXT_surface_maintenance1 : extension revision 1
VK_EXT_swapchain_colorspace : extension revision 5
VK_KHR_device_group_creation : extension revision 1
VK_KHR_display : extension revision 23
VK_KHR_external_fence_capabilities : extension revision 1
VK_KHR_external_memory_capabilities : extension revision 1
VK_KHR_external_semaphore_capabilities : extension revision 1
VK_KHR_get_display_properties2 : extension revision 1
VK_KHR_get_physical_device_properties2 : extension revision 2
VK_KHR_get_surface_capabilities2 : extension revision 1
VK_KHR_portability_enumeration : extension revision 1
VK_KHR_surface : extension revision 25
VK_KHR_surface_maintenance1 : extension revision 1
VK_KHR_surface_protected_capabilities : extension revision 1
VK_KHR_wayland_surface : extension revision 6
VK_KHR_xcb_surface : extension revision 6
VK_KHR_xlib_surface : extension revision 6
VK_LUNARG_direct_driver_loading : extension revision 1
VK_NV_display_stereo : extension revision 1
Instance Layers: count = 12
---------------------------
VK_LAYER_INTEL_nullhw INTEL NULL HW 1.1.73 version 1
VK_LAYER_KHRONOS_validation Khronos Validation Layer 1.4.341 version 1
VK_LAYER_MESA_anti_lag Open-source implementation of the VK_AMD_anti_lag extension. 1.4.303 version 1
VK_LAYER_MESA_device_select Linux device selection layer 1.4.303 version 1
VK_LAYER_MESA_overlay Mesa Overlay layer 1.4.303 version 1
VK_LAYER_MESA_screenshot Mesa Screenshot layer 1.4.303 version 1
VK_LAYER_NV_optimus NVIDIA Optimus layer 1.4.329 version 1
VK_LAYER_NV_present NVIDIA Presentation Layer 1.4.329 version 1
VK_LAYER_VALVE_steam_fossilize_32 Steam Pipeline Caching Layer 1.3.207 version 1
VK_LAYER_VALVE_steam_fossilize_64 Steam Pipeline Caching Layer 1.3.207 version 1
VK_LAYER_VALVE_steam_overlay_32 Steam Overlay Layer 1.3.207 version 1
VK_LAYER_VALVE_steam_overlay_64 Steam Overlay Layer 1.3.207 version 1
Devices:
========
GPU0:
apiVersion = 1.4.329
driverVersion = 595.84.0.0
vendorID = 0x10de
deviceID = 0x1e04
deviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU
deviceName = NVIDIA GeForce RTX 2080 Ti
driverID = DRIVER_ID_NVIDIA_PROPRIETARY
driverName = NVIDIA
driverInfo = 595.84
conformanceVersion = 1.4.3.3
deviceUUID = 2c71c7ba-97ac-c78e-2c06-af362631f3fe
driverUUID = 027cbdd1-c478-513e-af69-ebc9eaa7de87
GPU1:
apiVersion = 1.4.335
driverVersion = 26.0.8
vendorID = 0x10005
deviceID = 0x0000
deviceType = PHYSICAL_DEVICE_TYPE_CPU
deviceName = llvmpipe (LLVM 21.1.8, 256 bits)
driverID = DRIVER_ID_MESA_LLVMPIPE
driverName = llvmpipe
driverInfo = Mesa 26.0.8-1ubuntu0.3 (LLVM 21.1.8)
conformanceVersion = 1.3.1.1
deviceUUID = 6d657361-3236-2e30-2e38-2d3175627500
driverUUID = 6c6c766d-7069-7065-5555-494400000000
WARNING: [Loader Message] Code 0 : ICD for selected physical device does not export vkGetPhysicalDeviceDisplayPlanePropertiesKHR!
WARNING: [Loader Message] Code 0 : ICD for selected physical device does not export vkGetPhysicalDeviceDisplayPropertiesKHR!
@@ -0,0 +1,42 @@
# P1 starter projects: Linux checkpoint (2026-09-24)
This is a focused validation of the four explicit new-project choices in commit
`cbb8b4508723e1a3a0bbd53c884e116a47b121dc`. It is not the final P1 acceptance
record. The run used Linux 7.0.0-31-generic x86_64, Clang 21.1.8, CMake 4.2.3,
and an NVIDIA GeForce RTX 2080 Ti with driver 595.84.
The Editor CLI created C++ 2D, C++ 3D, Lua 2D, and Lua 3D projects in disposable
directories with `--new`, `--dimension`, and `--language`. The test checked the
project manifest, authored start scene, behavior binding, Lua script declaration,
LuaLS files, and the absence of a C++ gameplay stub in Lua-only projects. A real
Debug `faset_build` succeeded for C++ 2D and Lua 3D. Their built Players then
validated and rendered **both** corresponding scene dimensions using the same
language module, so all four templates exercised runtime schema and rendering.
Each headless one-frame render reported `validation_errors: 0` on the physical GPU.
| Project | Built Player used | `--validate` | One-frame headless render |
| --- | --- | --- | --- |
| C++ 2D | C++ 2D | passed | passed |
| C++ 3D | C++ 2D | passed | passed |
| Lua 2D | Lua 3D | passed | passed |
| Lua 3D | Lua 3D | passed | passed |
The C++ and Lua source files are identical within each language choice; the 2D/3D
difference is the authored scene. Reusing one built Player per language therefore
checks scene validity without repeating an identical native compile. The focused
CTest cases `process_and_cook` and `editor_ui_launcher` passed 2/2. They include a
two-service concurrent creation test: exactly one starter wins, and the other
cannot overwrite it. A fresh Lua starter was initialized as a Git repository;
`git check-ignore` confirmed `.faset/cache` is ignored while the LuaLS API
declaration `.faset/lua/faset.lua` can be committed. A strict MkDocs build passed.
These captures are representative output from the C++ 2D and 3D scenes. Lua
scenes rendered the same layout with their own behavior module.
![C++ 2D starter after one frame](p1-starters-linux-2026-09-24/cpp-2.png)
![C++ 3D starter after one frame](p1-starters-linux-2026-09-24/cpp-3.png)
The run did not test Windows, a relocated Release export, multi-frame gameplay
input, or the final P1 build-cache integration. Those belong to the P1 acceptance
matrix and must be recorded separately.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

+2 -1
View File
@@ -25,7 +25,8 @@ class AuthoringService {
const Json& operations, const std::string& idempotency_key = "");
Json undo(const std::string& document, std::uint64_t expected_revision);
Json redo(const std::string& document, std::uint64_t expected_revision);
Json save(const std::string& document, const std::filesystem::path& relative = {});
Json save(const std::string& document, const std::filesystem::path& relative = {},
std::optional<std::uint64_t> expected_revision = std::nullopt);
Json recovery_documents() const;
Json recover(const std::string& document,
std::optional<std::uint64_t> expected_revision = std::nullopt);
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <faset/core/json.hpp>
#include <chrono>
#include <functional>
#include <map>
#include <mutex>
#include <string>
namespace faset::editor {
class AutosaveController {
public:
using Clock = std::chrono::steady_clock;
using SaveFn = std::function<Json(const std::string&, std::uint64_t)>;
explicit AutosaveController(SaveFn save);
// Called from the Editor event loop with an injected monotonic clock for
// deterministic tests. Only a named dirty scene becomes eligible to save.
void observe(const Json& documents, Clock::time_point now, bool enabled);
Json status() const;
private:
struct Entry {
std::uint64_t revision{};
std::string path, state = "saved", error;
Clock::time_point deadline{};
};
SaveFn save_;
mutable std::mutex mutex_;
std::map<std::string, Entry> entries_;
bool enabled_ = true;
};
} // namespace faset::editor
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <filesystem>
#include <optional>
#include <string>
namespace faset::editor {
struct BuildInputs {
std::string source_hash;
std::string recipe_hash;
std::string toolchain_hash;
std::string fingerprint() const;
};
// Hash all files in Scripts and the active Lua declaration, not only the two
// conventional gameplay files. Both Editor stale-state and builds use this key.
std::string gameplay_source_hash(const std::filesystem::path& project_root,
const scripting::LuaProject& lua);
BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua);
std::optional<std::string> configured_cmake_value(const BuildConfig& config,
std::string_view key);
// Copy a content-addressed, immutable Scripts tree for the native compiler.
// Returning the same path for equal source bytes preserves Ninja incrementality.
std::filesystem::path stage_gameplay_sources(const BuildConfig& config,
const BuildInputs& inputs,
const scripting::LuaProject& lua,
std::string_view job_id);
// A compiler replaced at the same path may be invisible to Ninja. Invalidate
// only this generated native tree; published build generations stay intact.
void ensure_native_toolchain_stamp(const BuildConfig& config,
const std::filesystem::path& native_directory,
const BuildInputs& inputs);
// Native build completes before package reuse is considered. Hash the actual
// generated outputs, not their timestamps or just the gameplay sources.
std::string build_package_key(const BuildInputs& inputs,
const std::filesystem::path& native_directory,
const std::string& configuration,
const std::filesystem::path& player,
const std::filesystem::path& exporter);
// A hit is accepted only if the complete published file set and schema match
// the manifest. Malformed or old manifests are safe cache misses.
bool validate_build_generation(const std::filesystem::path& directory,
const std::string& package_key);
} // namespace faset::editor
@@ -0,0 +1,15 @@
#pragma once
#include <faset/core/json.hpp>
#include <filesystem>
#include <string_view>
namespace faset::editor {
// Parse compiler, MSVC/clang-cl and Lua output into bounded Editor-facing rows.
// Only sources under the current project's Scripts directory receive a file
// key, so clients cannot offer navigation to unrelated filesystem paths.
Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase,
const std::filesystem::path& project_root);
} // namespace faset::editor
+5
View File
@@ -3,6 +3,7 @@
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace faset::editor {
@@ -22,6 +23,7 @@ struct JobStatus {
std::string id, kind, state{"queued"}, stage{"queued"};
double progress{};
std::string log, error;
Json diagnostics = Json::array();
Json result = Json::object();
bool finished() const {
return state == "succeeded" || state == "failed" || state == "cancelled";
@@ -37,6 +39,9 @@ class BuildService {
BuildService(const BuildService&) = delete;
BuildService& operator=(const BuildService&) = delete;
void scaffold(const std::string& name, int dimension);
// Explicit starter choice creates a runnable start scene. The legacy overload above
// remains intentionally scene-free for existing command-line and API callers.
void scaffold(const std::string& name, int dimension, std::string_view language);
std::string start_build();
std::string start_cook(Json resolved_scene);
// Publishes output/generations/<id>; current.json changes only after all validation succeeds.
+7 -2
View File
@@ -8,6 +8,7 @@ struct ProjectSelection {
std::string name;
int dimension = 3;
bool create = false;
std::string language = "cpp";
};
// Records only existing, valid projects. Call after successful Session setup.
void remember_project(const std::filesystem::path& project);
@@ -15,7 +16,9 @@ class ProjectLauncher {
public:
ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {},
const std::filesystem::path& recent_store = {});
const std::filesystem::path& recent_store = {},
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
~ProjectLauncher();
void frame(const std::vector<render::Event>&);
const render::Snapshot& snapshot() const;
@@ -30,5 +33,7 @@ class ProjectLauncher {
std::optional<ProjectSelection>
run_project_launcher(const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {},
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {});
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {},
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
} // namespace faset::editor
+5
View File
@@ -2,10 +2,12 @@
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/editor/autosave.hpp>
#include <faset/editor/commands.hpp>
#include <faset/editor/plugins.hpp>
#include <memory>
#include <mutex>
#include <string_view>
#include <thread>
namespace faset::editor {
@@ -33,6 +35,7 @@ class Session {
return plugins_ ? plugins_->panels() : Json::array();
}
void scaffold(const std::string& name, int dimension);
void scaffold(const std::string& name, int dimension, std::string_view language);
const SessionConfig& config() const {
return config_;
}
@@ -60,6 +63,8 @@ class Session {
Commands commands_;
assets::AssetPipeline assets_;
BuildService builds_;
AutosaveController autosave_;
bool autosave_enabled_ = true;
std::map<std::string, std::shared_ptr<ImportTask>> imports_;
std::vector<std::jthread> workers_;
std::vector<std::string> logs_;
+4 -1
View File
@@ -573,9 +573,12 @@ Json AuthoringService::undo(const std::string& id, std::uint64_t revision) {
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
return history(id, revision, true);
}
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) {
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative,
std::optional<std::uint64_t> expected_revision) {
std::lock_guard lock(mutex_);
auto& current = state(id);
if (expected_revision)
check_revision(current.revision, *expected_revision);
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
require(!selected.empty(), "save.path", "Choose a scene path before saving");
const auto path = project_path(root_, selected);
+88
View File
@@ -0,0 +1,88 @@
#include <faset/editor/autosave.hpp>
#include <faset/core/error.hpp>
#include <set>
#include <stdexcept>
namespace faset::editor {
AutosaveController::AutosaveController(SaveFn save) : save_(std::move(save)) {
if (!save_)
throw std::invalid_argument("Autosave requires a save callback");
}
void AutosaveController::observe(const Json& documents, Clock::time_point now, bool enabled) {
std::lock_guard lock(mutex_);
enabled_ = enabled;
std::set<std::string> present;
for (const auto& document : documents) {
const auto id = document.at("id").get<std::string>();
const auto revision = document.at("revision").get<std::uint64_t>();
const auto path = document.value("path", std::string());
const auto dirty = document.value("dirty", false);
present.insert(id);
auto [found, inserted] = entries_.try_emplace(id);
auto& entry = found->second;
const auto changed = inserted || entry.revision != revision || entry.path != path;
const auto prior_state = entry.state;
entry.revision = revision;
entry.path = path;
if (!dirty) {
entry.state = "saved";
entry.error.clear();
continue;
}
if (path.empty()) {
entry.state = "save_as_required";
entry.error.clear();
continue;
}
if (!enabled) {
entry.state = "disabled";
entry.error.clear();
continue;
}
if (changed || prior_state == "disabled" || prior_state == "saved" ||
prior_state == "save_as_required") {
entry.state = "pending";
entry.error.clear();
entry.deadline = now + std::chrono::seconds(2);
}
if (entry.state != "pending" || now < entry.deadline)
continue;
entry.state = "saving";
try {
const auto saved = save_(id, revision);
if (saved.at("revision").get<std::uint64_t>() != revision ||
saved.at("dirty").get<bool>())
throw std::runtime_error("Save returned an unexpected document revision");
entry.state = "saved";
entry.error.clear();
} catch (const Error& error) {
entry.state = error.code() == "save.disk_conflict" ||
error.code() == "revision.conflict"
? "conflict"
: "failed";
entry.error = error.what();
} catch (const std::exception& error) {
entry.state = "failed";
entry.error = error.what();
}
}
for (auto it = entries_.begin(); it != entries_.end();)
it = present.contains(it->first) ? std::next(it) : entries_.erase(it);
}
Json AutosaveController::status() const {
std::lock_guard lock(mutex_);
Json documents = Json::array();
for (const auto& [id, entry] : entries_)
documents.push_back({{"id", id},
{"state", entry.state},
{"revision", entry.revision},
{"path", entry.path},
{"error", entry.error}});
return {{"enabled", enabled_}, {"documents", documents}};
}
} // namespace faset::editor
+393
View File
@@ -0,0 +1,393 @@
#include <faset/editor/build_cache.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <cctype>
#include <cstdlib>
#include <set>
#include <stdexcept>
namespace faset::editor {
namespace fs = std::filesystem;
namespace {
std::string normalized_hash(const Json& value) {
return sha256(value.dump());
}
std::pair<std::string, std::string> parse_define(const std::string& argument) {
if (!argument.starts_with("-D"))
throw std::invalid_argument("Gameplay configure arguments must be -DNAME=value options");
const auto equal = argument.find('=', 2);
if (equal == std::string::npos || equal == 2)
throw std::invalid_argument("Invalid gameplay configure definition");
const auto colon = argument.find(':', 2);
const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2);
if (key.empty())
throw std::invalid_argument("Invalid gameplay configure definition");
return {key, argument.substr(equal + 1)};
}
void validate_configure_arguments(const BuildConfig& config) {
static const std::set<std::string> controlled = {
"FASET_GAMEPLAY_SOURCE_DIR", "FASET_ENABLE_LUA", "CMAKE_BUILD_TYPE", "BUILD_TESTING",
"FASET_BUILD_EDITOR", "FASET_BUILD_RENDERER", "FASET_BUILD_RUNTIME", "FASET_BUILD_ASSETS"};
for (const auto& argument : config.configure_arguments)
if (controlled.contains(parse_define(argument).first))
throw std::invalid_argument("Cannot override an engine-controlled CMake definition");
}
std::string configured_tool(const BuildConfig& config, std::string_view key,
std::string fallback) {
if (auto value = configured_cmake_value(config, key))
return *value;
return fallback;
}
fs::path resolve_tool(const std::string& name, const fs::path& working_directory) {
const auto supplied = path_from_utf8(name);
if (supplied.has_parent_path())
return (supplied.is_absolute() ? supplied : working_directory / supplied).lexically_normal();
try {
return find_executable(name);
} catch (const std::exception&) {
// CMake reports a missing compiler. Keep the identity deterministic so
// fixture build tools can exercise publication without native compiling.
return supplied;
}
}
Json tool_identity(const std::string& name, const fs::path& working_directory) {
const auto resolved = resolve_tool(name, working_directory);
Json result = {{"path", path_to_utf8(resolved)}};
if (fs::is_regular_file(resolved))
result["sha256"] = sha256_file(resolved);
else
result["missing"] = true;
return result;
}
std::string slang_name(const BuildConfig& config) {
auto configured = configured_tool(config, "SLANGC_EXECUTABLE", "");
if (!configured.empty())
return configured;
#ifdef _WIN32
const auto local = config.engine_root / ".cache/slang/bin/slangc.exe";
#else
const auto local = config.engine_root / ".cache/slang/bin/slangc";
#endif
if (fs::is_regular_file(local))
return path_to_utf8(local);
if (const char* sdk = std::getenv("VULKAN_SDK")) {
#ifdef _WIN32
const auto sdk_tool = path_from_utf8(sdk) / "Bin/slangc.exe";
#else
const auto sdk_tool = path_from_utf8(sdk) / "bin/slangc";
#endif
if (fs::is_regular_file(sdk_tool))
return path_to_utf8(sdk_tool);
}
return "slangc";
}
Json recipe_files(const fs::path& engine_root) {
Json files = Json::object();
for (const auto& relative : {"CMakeLists.txt", "dependencies.lock.json",
"tools/compile_shader.py"}) {
const auto path = engine_root / relative;
files[relative] = fs::is_regular_file(path) ? Json(sha256_file(path)) : Json(nullptr);
}
const auto cmake = engine_root / "cmake";
if (fs::is_directory(cmake))
for (const auto& entry : fs::directory_iterator(cmake))
if (entry.is_regular_file() && entry.path().extension() == ".cmake")
files[generic_path_to_utf8(entry.path().lexically_relative(engine_root))] =
sha256_file(entry.path());
return files;
}
bool within(const fs::path& path, const fs::path& directory) {
const auto relative = path.lexically_relative(directory);
if (relative.empty() || relative.is_absolute())
return false;
for (const auto& component : relative)
if (component == "..")
return false;
return true;
}
void refuse_symlink_ancestors(const fs::path& path) {
fs::path prefix;
for (const auto& component : path) {
prefix /= component;
std::error_code error;
if (fs::is_symlink(fs::symlink_status(prefix, error)))
throw std::runtime_error("Native build path contains a symlink: " +
path_to_utf8(prefix));
}
}
} // namespace
std::optional<std::string> configured_cmake_value(const BuildConfig& config,
std::string_view key) {
std::optional<std::string> result;
for (const auto& argument : config.configure_arguments) {
const auto [name, value] = parse_define(argument);
if (name == key)
result = value;
}
return result;
}
std::string BuildInputs::fingerprint() const {
return normalized_hash({{"format", "faset.build-inputs.v1"},
{"source", source_hash},
{"recipe", recipe_hash},
{"toolchain", toolchain_hash}});
}
std::string gameplay_source_hash(const fs::path& project_root, const scripting::LuaProject& lua) {
Json files = Json::object();
const auto scripts = project_root / "Scripts";
if (fs::is_symlink(fs::symlink_status(scripts)))
throw std::runtime_error("Scripts directory must not be a symlink");
if (fs::is_directory(scripts))
for (const auto& entry : fs::recursive_directory_iterator(scripts)) {
if (entry.is_symlink())
throw std::runtime_error("Scripts source cannot be a symlink: " +
path_to_utf8(entry.path()));
if (entry.is_regular_file())
files[generic_path_to_utf8(entry.path().lexically_relative(project_root))] =
sha256_file(entry.path());
}
return normalized_hash({{"format", "faset.gameplay-sources.v1"},
{"files", files},
{"lua", lua.fingerprint}});
}
BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua) {
validate_configure_arguments(config);
BuildInputs result;
result.source_hash = gameplay_source_hash(config.project_root, lua);
result.recipe_hash = normalized_hash({{"format", "faset.build-recipe.v1"},
{"generator", config.generator},
{"configuration", config.configuration},
{"export_configuration", config.export_configuration},
{"configure_arguments", config.configure_arguments},
{"engine_files", recipe_files(config.engine_root)}});
#ifdef _WIN32
constexpr auto default_c = "clang-cl", default_cxx = "clang-cl";
#else
constexpr auto default_c = "clang", default_cxx = "clang++";
#endif
result.toolchain_hash = normalized_hash(
{{"format", "faset.native-toolchain.v1"},
{"cmake", tool_identity(config.cmake, config.project_root)},
{"c", tool_identity(configured_tool(config, "CMAKE_C_COMPILER", default_c),
config.project_root)},
{"cxx", tool_identity(configured_tool(config, "CMAKE_CXX_COMPILER", default_cxx),
config.project_root)},
{"slang", tool_identity(slang_name(config), config.project_root)},
{"toolchain_file", tool_identity(configured_tool(config, "CMAKE_TOOLCHAIN_FILE", ""),
config.project_root)}});
return result;
}
fs::path stage_gameplay_sources(const BuildConfig& config, const BuildInputs& inputs,
const scripting::LuaProject& lua, std::string_view job_id) {
const auto cache = config.cache_root.empty() ? config.project_root / ".faset" / "cache"
: config.cache_root;
const auto snapshots = cache / "source-snapshots";
const auto destination = snapshots / inputs.source_hash;
const auto verified = [&](const fs::path& root) {
return fs::is_directory(root / "Scripts") && !fs::is_symlink(root) &&
gameplay_source_hash(root, lua) == inputs.source_hash;
};
if (fs::exists(destination)) {
if (!verified(destination))
throw std::runtime_error("Cached gameplay source snapshot is corrupt");
return destination / "Scripts";
}
const auto source = config.project_root / "Scripts";
if (!fs::is_directory(source) || fs::is_symlink(source))
throw std::runtime_error("Gameplay Scripts directory is missing or a symlink");
const auto staging = snapshots / (".staging-" + std::string(job_id));
if (fs::exists(staging))
throw std::runtime_error("Gameplay source staging directory already exists");
fs::create_directories(staging / "Scripts");
try {
for (const auto& entry : fs::recursive_directory_iterator(source)) {
if (entry.is_symlink())
throw std::runtime_error("Gameplay source snapshot contains a symlink");
const auto target = staging / "Scripts" / entry.path().lexically_relative(source);
if (entry.is_directory())
fs::create_directories(target);
else if (entry.is_regular_file()) {
fs::create_directories(target.parent_path());
fs::copy_file(entry.path(), target);
}
}
if (!verified(staging))
throw std::runtime_error("Gameplay sources changed while creating the build snapshot");
fs::rename(staging, destination);
return destination / "Scripts";
} catch (...) {
std::error_code ignored;
fs::remove_all(staging, ignored);
if (fs::exists(destination) && verified(destination))
return destination / "Scripts";
throw;
}
}
void ensure_native_toolchain_stamp(const BuildConfig& config, const fs::path& native_directory,
const BuildInputs& inputs) {
const auto project = fs::absolute(config.project_root).lexically_normal();
const auto base = fs::absolute(config.build_directory).lexically_normal();
const auto native = fs::absolute(native_directory).lexically_normal();
const auto cache = fs::absolute(config.cache_root.empty()
? project / ".faset" / "cache"
: config.cache_root).lexically_normal();
const auto engine = fs::absolute(config.engine_root).lexically_normal();
if (native.parent_path() != base ||
(native.filename() != "Debug" && native.filename() != "Release" &&
native.filename() != "RelWithDebInfo"))
throw std::runtime_error("Native build path is outside its configured managed root");
const auto default_base = project / ".faset" / "build";
if ((within(base, project) && base != default_base) || within(project, base) ||
within(base, cache) || within(cache, base) || within(base, engine) ||
within(engine, base))
throw std::runtime_error("Native build root overlaps project, cache or engine files");
refuse_symlink_ancestors(native);
const auto stamp = native_directory / ".faset-toolchain.json";
bool matching = false, owned = false;
if (fs::is_regular_file(stamp) && !fs::is_symlink(stamp))
try {
const auto existing = read_json(stamp);
const auto format = existing.value("format", std::string()) ==
"faset.toolchain-stamp";
const auto version = existing.value("version", 0);
owned = format &&
((version == 2 &&
existing.value("project_root", std::string()) == path_to_utf8(project) &&
existing.value("native_directory", std::string()) == path_to_utf8(native)) ||
(version == 1 && base == default_base));
matching = owned &&
existing.value("toolchain_hash", std::string()) == inputs.toolchain_hash;
} catch (const std::exception&) {
owned = false;
}
if (fs::exists(native_directory) && !owned)
throw std::runtime_error("Refusing to clear an unowned native build directory: " +
path_to_utf8(native));
if (!matching && fs::exists(native_directory))
fs::remove_all(native_directory);
fs::create_directories(native_directory);
atomic_write_json(stamp, {{"format", "faset.toolchain-stamp"},
{"version", 2},
{"project_root", path_to_utf8(project)},
{"native_directory", path_to_utf8(native)},
{"toolchain_hash", inputs.toolchain_hash}});
}
std::string build_package_key(const BuildInputs& inputs, const fs::path& native_directory,
const std::string& configuration, const fs::path& player,
const fs::path& exporter) {
Json files = Json::object();
for (const auto& [name, path] :
{std::pair{"player", player}, std::pair{"exporter", exporter},
std::pair{"cmake_cache", native_directory / "CMakeCache.txt"}}) {
if (!fs::is_regular_file(path) || fs::is_symlink(path))
throw std::runtime_error("Native build artifact is missing: " + path_to_utf8(path));
files[name] = sha256_file(path);
}
const auto shaders = native_directory / "shaders";
if (!fs::is_directory(shaders))
throw std::runtime_error("Native shader directory is missing");
for (const auto& entry : fs::recursive_directory_iterator(shaders)) {
if (entry.is_symlink())
throw std::runtime_error("Native shader is a symlink");
if (entry.is_regular_file())
files[generic_path_to_utf8(entry.path().lexically_relative(native_directory))] =
sha256_file(entry.path());
}
// On Windows, a changed runtime DLL must also invalidate a package hit.
for (const auto& root : {player.parent_path(), native_directory,
native_directory / configuration}) {
if (!fs::is_directory(root))
continue;
for (const auto& entry : fs::directory_iterator(root)) {
auto extension = path_to_utf8(entry.path().extension());
for (auto& character : extension)
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
if (extension != ".dll")
continue;
if (!entry.is_regular_file() || entry.is_symlink())
throw std::runtime_error("Native runtime DLL is not a regular file");
files["dll:" + path_to_utf8(entry.path().filename())] = sha256_file(entry.path());
}
}
return normalized_hash({{"format", "faset.package-key.v1"},
{"inputs", inputs.fingerprint()},
{"configuration", configuration},
{"files", files}});
}
bool validate_build_generation(const fs::path& directory, const std::string& package_key) {
try {
if (!fs::is_directory(directory) || fs::is_symlink(directory))
return false;
const auto manifest_file = directory / "manifest.json";
if (!fs::is_regular_file(manifest_file) || fs::is_symlink(manifest_file))
return false;
const auto manifest = read_json(manifest_file);
if (manifest.at("format") != "faset.build" || manifest.at("version") != 2 ||
manifest.at("package_key") != package_key || !manifest.at("files").is_array())
return false;
std::set<std::string> expected;
for (const auto& record : manifest.at("files")) {
const auto name = record.at("path").get<std::string>();
const auto relative = path_from_utf8(name);
if (relative.empty() || relative.is_absolute() || name == "manifest.json" ||
generic_path_to_utf8(relative) != name)
return false;
for (const auto& component : relative)
if (component == "." || component == "..")
return false;
if (!expected.insert(name).second)
return false;
auto file = directory;
for (const auto& component : relative) {
file /= component;
if (fs::is_symlink(file))
return false;
}
if (!fs::is_regular_file(file) || fs::is_symlink(file) ||
sha256_file(file) != record.at("sha256").get<std::string>() ||
fs::file_size(file) != record.at("size").get<std::uintmax_t>())
return false;
}
for (const auto& entry : fs::recursive_directory_iterator(directory)) {
if (entry.is_symlink())
return false;
if (entry.is_regular_file()) {
const auto name = generic_path_to_utf8(entry.path().lexically_relative(directory));
if (name != "manifest.json" && !expected.contains(name))
return false;
}
}
const auto player_name = manifest.at("player").get<std::string>();
const auto schema_name = manifest.at("schema").get<std::string>();
if (!expected.contains(player_name) || !expected.contains(schema_name) ||
!expected.contains("faset_schema_exporter" + fs::path(player_name).extension().string()))
return false;
for (const auto* shader : {"vertexMain", "fragmentMain", "shadowMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
if (!expected.contains("shaders/" + std::string(shader) + extension))
return false;
const auto schema = read_json(directory / schema_name);
if (schema.at("format") != "faset.schema" || schema.at("version") != 1 ||
schema.at("build_fingerprint") != manifest.at("fingerprint"))
return false;
(void)authoring::gameplay_schemas(schema);
return true;
} catch (const std::exception&) {
return false;
}
}
} // namespace faset::editor
+149
View File
@@ -0,0 +1,149 @@
#include <faset/editor/build_diagnostics.hpp>
#include <faset/core/io.hpp>
#include <algorithm>
#include <cctype>
#include <regex>
#include <sstream>
namespace faset::editor {
namespace {
constexpr std::size_t max_rows = 200;
constexpr std::size_t max_line = 8192;
constexpr std::size_t max_message = 2048;
std::string strip_ansi(std::string_view text) {
std::string clean;
clean.reserve(text.size());
for (std::size_t index = 0; index < text.size(); ++index) {
if (text[index] == '\x1b' && index + 1 < text.size() && text[index + 1] == '[') {
index += 2;
while (index < text.size() && !(text[index] >= '@' && text[index] <= '~'))
++index;
continue;
}
clean += text[index];
}
return clean;
}
std::string normalize_path(std::string path) {
std::replace(path.begin(), path.end(), '\\', '/');
// Compiler output is UTF-8. Constructing a path from a narrow string on
// Windows can decode it through the process code page and lose Unicode.
path = generic_path_to_utf8(path_from_utf8(path).lexically_normal());
while (path.size() > 1 && path.back() == '/')
path.pop_back();
return path;
}
bool has_windows_drive(std::string_view path) {
return path.size() >= 2 && std::isalpha(static_cast<unsigned char>(path[0])) &&
path[1] == ':';
}
std::string lower_ascii(std::string value) {
for (auto& character : value)
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
return value;
}
std::string project_source(std::string raw, const std::filesystem::path& project_root) {
if (raw.starts_with("lua: "))
raw.erase(0, 5);
std::replace(raw.begin(), raw.end(), '\\', '/');
for (const auto& component : path_from_utf8(raw))
if (component == "..")
return {};
const auto path = normalize_path(std::move(raw));
const auto root = normalize_path(path_to_utf8(project_root));
std::string relative;
if (!path.empty() && path[0] != '/' && !has_windows_drive(path))
relative = path;
else {
const auto windows = has_windows_drive(path) && has_windows_drive(root);
const auto comparable_path = windows ? lower_ascii(path) : path;
const auto comparable_root = windows ? lower_ascii(root) : root;
if (comparable_path.size() <= comparable_root.size() ||
!comparable_path.starts_with(comparable_root) ||
comparable_path[comparable_root.size()] != '/')
return {};
relative = path.substr(root.size() + 1);
}
if (!relative.starts_with("Scripts/") || relative.size() <= 8)
return {};
if (relative.find("/../") != std::string::npos || relative.ends_with("/.."))
return {};
return relative;
}
int positive_number(const std::string& text) {
try {
const auto value = std::stoll(text);
return value > 0 && value <= 100000000 ? static_cast<int>(value) : 0;
} catch (const std::exception&) {
return 0;
}
}
} // namespace
Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase,
const std::filesystem::path& project_root) {
static const std::regex msvc(
R"(^(.+)\(([0-9]+)(?:,([0-9]+))?\)\s*:\s*(fatal error|error|warning|note)\s*([A-Za-z]+[0-9]+)?\s*:\s*(.*)$)");
static const std::regex clang_column(
R"(^(.+):([0-9]+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
static const std::regex clang_line(
R"(^(.+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
static const std::regex lua(R"(^(.+\.lua):([0-9]+):\s*(.*)$)");
Json rows = Json::array();
std::istringstream stream{std::string(raw_log)};
std::string raw;
while (rows.size() < max_rows && std::getline(stream, raw)) {
if (raw.size() > max_line)
raw.resize(max_line);
auto line = strip_ansi(raw);
if (!line.empty() && line.back() == '\r')
line.pop_back();
std::smatch match;
std::string file, message, severity, code;
int row = 0, column = 0;
bool has_column = false;
if (std::regex_match(line, match, msvc) ||
std::regex_match(line, match, clang_column)) {
file = match[1].str();
row = positive_number(match[2].str());
has_column = !match[3].str().empty();
column = positive_number(match[3].str());
severity = match[4].str();
code = match[5].str();
message = match[6].str();
} else if (std::regex_match(line, match, clang_line)) {
file = match[1].str();
row = positive_number(match[2].str());
severity = match[3].str();
code = match[4].str();
message = match[5].str();
} else if (std::regex_match(line, match, lua)) {
file = match[1].str();
row = positive_number(match[2].str());
severity = "error";
message = match[3].str();
} else
continue;
if (row == 0 || (has_column && column == 0))
continue;
if (severity == "fatal error")
severity = "error";
if (message.size() > max_message)
message.resize(max_message);
Json diagnostic{{"severity", severity},
{"phase", std::string(phase)},
{"message", message},
{"line", row}};
if (column > 0)
diagnostic["column"] = column;
if (!code.empty())
diagnostic["code"] = code;
if (auto relative = project_source(file, project_root); !relative.empty())
diagnostic["file"] = std::move(relative);
rows.push_back(std::move(diagnostic));
}
return rows;
}
} // namespace faset::editor
+345 -36
View File
@@ -1,3 +1,4 @@
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
@@ -6,9 +7,12 @@
#include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_diagnostics.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <fstream>
@@ -89,11 +93,107 @@ fs::path build_executable(const fs::path& build, const std::string& configuratio
}
throw std::runtime_error("Build did not produce " + target);
}
Json starter_scene(const std::string& name, int dimension, std::string_view language) {
const auto schemas = authoring::builtin_schemas();
auto scene = authoring::make_scene(name + " — Starter", dimension);
scene["simulation"] = authoring::default_simulation_settings();
auto add_component = [&](Json& entity, std::string type, Json fields) {
entity["components"].push_back({{"id", new_id()},
{"type", std::move(type)},
{"version", 1},
{"fields", std::move(fields)}});
};
auto ground = authoring::make_entity(schemas, "Ground");
auto& ground_pose = ground["components"][0]["fields"];
ground_pose["position"] = {0, -1, 0};
ground_pose["scale"] = dimension == 2 ? Json::array({12, 1, 1}) : Json::array({12, 1, 12});
if (dimension == 2) {
add_component(ground, "faset.sprite", {{"size", {1, 1}},
{"color", {0.20, 0.25, 0.31, 1}}});
} else {
add_component(ground, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.20, 0.25, 0.31, 1}}});
}
auto ground_body = schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) +
"d");
ground_body["body_type"] = "static";
add_component(ground, "faset.rigid_body_" + std::to_string(dimension) + "d",
std::move(ground_body));
scene["entities"].push_back(std::move(ground));
auto actor = authoring::make_entity(schemas, "Player");
actor["components"][0]["fields"]["position"] = {0, 1, 0};
if (dimension == 2) {
add_component(actor, "faset.sprite", {{"size", {0.8, 0.8}},
{"color", {0.25, 0.72, 0.85, 1}}});
} else {
actor["components"][0]["fields"]["scale"] = {0.8, 0.8, 0.8};
add_component(actor, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.25, 0.72, 0.85, 1}}});
}
add_component(actor, "faset.rigid_body_" + std::to_string(dimension) + "d",
schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) + "d"));
add_component(actor, language == "lua" ? "starter.player" : "gameplay.character",
{{"speed", 4.0}, {"jump_speed", 5.0}});
scene["entities"].push_back(std::move(actor));
if (dimension == 3) {
auto camera = authoring::make_entity(schemas, "Camera");
camera["components"][0]["fields"]["position"] = {0, 2.5, 8};
camera["components"][0]["fields"]["rotation"] = {-0.18, 0, 0};
add_component(camera, "faset.camera", schemas.default_fields("faset.camera"));
scene["entities"].push_back(std::move(camera));
auto sun = authoring::make_entity(schemas, "Sun");
add_component(sun, "faset.light", schemas.default_fields("faset.light"));
scene["entities"].push_back(std::move(sun));
}
// Validate every built-in component now; the behavior component is validated by
// SchemaExporter from the selected C++/Lua module during the first build.
auto builtins = scene;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [](const Json& component) {
return !component.at("type").get<std::string>().starts_with("faset.");
});
}
authoring::validate_scene(builtins, schemas);
return scene;
}
struct StarterCreateLock {
fs::path path;
explicit StarterCreateLock(const fs::path& root) : path(root / ".faset/starter-create.lock") {
if (!fs::create_directory(path))
throw std::runtime_error("Another project creation is in progress; remove a stale " +
path_to_utf8(path) + " only after closing that Editor");
}
~StarterCreateLock() {
std::error_code ignored;
fs::remove_all(path, ignored);
}
StarterCreateLock(const StarterCreateLock&) = delete;
StarterCreateLock& operator=(const StarterCreateLock&) = delete;
};
void require_new_starter_directory(const fs::path& root) {
// BuildService creates .faset/cache in its constructor. Any other content is
// user-owned, including a previous starter, and must be left intact.
for (const auto& entry : fs::directory_iterator(root)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
throw std::runtime_error("Create requires a new or empty project directory");
for (const auto& internal : fs::directory_iterator(entry.path())) {
if (internal.path().filename() == "starter-create.lock" &&
internal.is_directory() && !internal.is_symlink())
continue;
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
throw std::runtime_error("Create requires a new or empty project directory");
}
}
}
} // namespace
Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state},
{"stage", stage}, {"progress", progress}, {"log", log},
{"error", error}, {"result", result}};
{"error", error}, {"diagnostics", diagnostics}, {"result", result}};
}
void write_cooked_scene(const fs::path& path, const Json& scene) {
validate_scene(scene);
@@ -198,6 +298,13 @@ struct BuildService::Impl {
std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
if (job.cancelled)
throw Cancelled{};
std::string phase;
std::size_t diagnostics_before;
{
std::lock_guard lock(job.mutex);
phase = job.status.stage;
diagnostics_before = job.status.diagnostics.size();
}
std::string description = "$";
for (const auto& argument : arguments)
description += " " + Json(argument).dump();
@@ -205,6 +312,35 @@ struct BuildService::Impl {
log(job, description);
Process process({std::move(arguments), cwd, {}});
std::string output;
std::string pending;
const auto collect_diagnostics = [&](std::string_view chunk, bool final) {
pending.append(chunk);
std::size_t newline;
while ((newline = pending.find('\n')) != std::string::npos) {
const auto line = pending.substr(0, newline + 1);
pending.erase(0, newline + 1);
const auto found = parse_build_diagnostics(line, phase, config.project_root);
if (found.empty())
continue;
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
}
if (final && !pending.empty()) {
const auto found = parse_build_diagnostics(pending, phase, config.project_root);
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
pending.clear();
} else if (pending.size() > 8192)
pending.erase(0, pending.size() - 8192);
};
while (true) {
if (job.cancelled) {
process.cancel();
@@ -214,14 +350,29 @@ struct BuildService::Impl {
}
auto poll = process.poll();
log(job, poll.output);
collect_diagnostics(poll.output, !poll.running);
output += poll.output;
if (output.size() > max_log_bytes)
output.erase(0, output.size() - max_log_bytes);
if (!poll.running) {
if (poll.exit_code.value_or(1) != 0)
if (poll.exit_code.value_or(1) != 0) {
std::lock_guard lock(job.mutex);
bool has_error = false;
for (std::size_t index = diagnostics_before;
index < job.status.diagnostics.size(); ++index)
if (job.status.diagnostics[index].value("severity", "") == "error")
has_error = true;
if (!has_error && job.status.diagnostics.size() < 200)
job.status.diagnostics.push_back(
{{"severity", "error"},
{"phase", phase},
{"message", "Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log"}});
throw std::runtime_error("Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log");
}
return output;
}
std::this_thread::sleep_for(std::chrono::milliseconds(15));
@@ -240,19 +391,23 @@ struct BuildService::Impl {
}
}
Json build(Job& job, bool exporting = false) {
const auto started = std::chrono::steady_clock::now();
const auto milliseconds = [](auto from, auto to) {
return std::chrono::duration_cast<std::chrono::milliseconds>(to - from).count();
};
const auto& configuration = exporting ? config.export_configuration : config.configuration;
const auto native_directory = config.build_directory / configuration;
checkpoint(job, "Configuring gameplay", .05);
job.lua = scripting::loadLuaProject(config.project_root);
const auto cpp = config.project_root / "Scripts" / "Gameplay.cpp";
const auto hpp = config.project_root / "Scripts" / "Gameplay.hpp";
const auto inputs = capture_build_inputs(config, job.lua);
const auto staged_scripts = stage_gameplay_sources(config, inputs, job.lua, job.status.id);
const auto cpp = staged_scripts / "Gameplay.cpp";
const auto hpp = staged_scripts / "Gameplay.hpp";
const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp);
if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled()))
throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, "
"or Lua entry scripts declared in project.faset.json");
const auto cpp_source = has_cpp ? read_text(cpp) : std::string{};
const auto hpp_source = has_hpp ? read_text(hpp) : std::string{};
fs::create_directories(native_directory);
ensure_native_toolchain_stamp(config, native_directory, inputs);
std::vector<std::string> arguments = {config.cmake,
"-S",
path_to_utf8(config.engine_root),
@@ -267,37 +422,100 @@ struct BuildService::Impl {
"-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
path_to_utf8(config.project_root / "Scripts")};
bool compiler_overridden = false;
for (const auto& arg : config.configure_arguments)
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
compiler_overridden = true;
if (!compiler_overridden) {
path_to_utf8(staged_scripts)};
if (!configured_cmake_value(config, "CMAKE_C_COMPILER")) {
#ifdef _WIN32
auto compiler = find_executable("clang-cl");
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler));
#else
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang")));
#endif
}
if (!configured_cmake_value(config, "CMAKE_CXX_COMPILER")) {
#ifdef _WIN32
arguments.push_back("-DCMAKE_CXX_COMPILER=" +
path_to_utf8(find_executable("clang-cl")));
#else
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
#endif
}
arguments.insert(arguments.end(), config.configure_arguments.begin(),
config.configure_arguments.end());
for (const auto& argument : config.configure_arguments) {
const auto equal = argument.find('=');
const auto colon = argument.find(':', 2);
const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2);
if (key == "CMAKE_C_COMPILER" || key == "CMAKE_CXX_COMPILER" ||
key == "SLANGC_EXECUTABLE" || key == "CMAKE_TOOLCHAIN_FILE") {
const auto value = path_from_utf8(argument.substr(equal + 1));
if (value.has_parent_path() && value.is_relative()) {
arguments.push_back(argument.substr(0, equal + 1) +
path_to_utf8((config.project_root / value).lexically_normal()));
continue;
}
}
arguments.push_back(argument);
}
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);
const auto configured = std::chrono::steady_clock::now();
checkpoint(job, "Compiling and linking Player", .25);
run(job,
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root);
checkpoint(job, "Exporting gameplay schema", .58);
const auto compiled = std::chrono::steady_clock::now();
auto player = build_executable(native_directory, configuration, "faset_player");
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter");
const auto package_key =
build_package_key(inputs, native_directory, configuration, player, exporter);
const auto source_unchanged = [&] {
if (gameplay_source_hash(config.project_root,
scripting::loadLuaProject(config.project_root)) !=
inputs.source_hash)
throw std::runtime_error("Gameplay sources changed during the build; build again");
};
const auto result_for = [&](const std::string& id, const std::string& fingerprint,
bool reused) -> Json {
const auto directory = config.cache_root / "builds" / id;
const auto finished = std::chrono::steady_clock::now();
return {{"generation", id},
{"directory", path_to_utf8(directory)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", path_to_utf8(directory / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(directory / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint},
{"schema_cache_hit", reused},
{"generation_reused", reused},
{"phase_times_ms",
{{"configure", milliseconds(started, configured)},
{"native_build", milliseconds(configured, compiled)},
{"schema_package", reused ? 0 : milliseconds(compiled, finished)},
{"total", milliseconds(started, finished)}}}};
};
const auto pointer_file = config.cache_root / "last_build.json";
if (fs::is_regular_file(pointer_file))
try {
const auto pointer = read_json(pointer_file);
const auto id = pointer.at("generation").get<std::string>();
const auto candidate =
project_path(config.cache_root, fs::path("builds") / id);
if (validate_build_generation(candidate, package_key)) {
source_unchanged();
checkpoint(job, "Reusing verified build generation", .68);
const auto manifest = read_json(candidate / "manifest.json");
log(job, "Verified schema/package cache hit: " + id + "\n");
return result_for(id, manifest.at("fingerprint").get<std::string>(), true);
}
} catch (const std::exception&) {
// A bad pointer or old/corrupt generation is a cache miss.
}
checkpoint(job, "Exporting gameplay schema", .58);
const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id);
const auto generation = config.cache_root / "builds" / job.status.id;
fs::create_directories(staging);
@@ -322,10 +540,7 @@ struct BuildService::Impl {
// Validate the complete metadata before publishing either the schema or
// its Player generation. Session uses this same authoring contract.
(void)authoring::gameplay_schemas(schema);
std::string fingerprint = sha256_file(player) + sha256_file(exporter) +
read_text(native_directory / "CMakeCache.txt");
fingerprint += cpp_source + hpp_source + job.lua.fingerprint;
fingerprint = sha256(fingerprint);
const auto fingerprint = package_key;
schema["build_fingerprint"] = fingerprint;
if (job.lua.enabled())
schema["lua_fingerprint"] = job.lua.fingerprint;
@@ -341,36 +556,39 @@ struct BuildService::Impl {
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
if (entry.is_symlink())
throw std::runtime_error("Build generation contains a symlink");
if (entry.is_regular_file())
files.push_back(
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
{"sha256", sha256_file(entry.path())},
{"size", entry.file_size()}});
}
Json manifest{{"format", "faset.build"},
{"version", 1},
{"version", 2},
{"id", job.status.id},
{"fingerprint", fingerprint},
{"package_key", package_key},
{"configuration", configuration},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"}};
{"schema", "schema.json"},
{"files", files}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68);
if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot changed during schema export");
if (scripting::loadLuaProject(config.project_root).fingerprint != job.lua.fingerprint ||
has_cpp != fs::is_regular_file(cpp) || has_hpp != fs::is_regular_file(hpp) ||
(has_cpp && (read_text(cpp) != cpp_source || read_text(hpp) != hpp_source)))
throw std::runtime_error("Gameplay sources changed during the build; build again");
source_unchanged();
if (!validate_build_generation(staging, package_key))
throw std::runtime_error("Build generation failed final integrity validation");
fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
return {{"generation", job.status.id},
{"directory", path_to_utf8(generation)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(generation / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint}};
return result_for(job.status.id, fingerprint, false);
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
@@ -785,6 +1003,97 @@ void BuildService::scaffold(const std::string& name, int dimension) {
if (!fs::exists(c.project_root / ".gitignore"))
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n");
}
void BuildService::scaffold(const std::string& name, int dimension,
std::string_view language) {
if (name.empty() || (dimension != 2 && dimension != 3) ||
(language != "cpp" && language != "lua"))
throw std::invalid_argument("Starter requires a name, 2D/3D and cpp/lua language");
const auto& c = impl_->config;
StarterCreateLock lock(c.project_root);
require_new_starter_directory(c.project_root);
const auto scene = starter_scene(name, dimension, language);
const auto source = c.engine_root / "tools/project_templates" /
(language == "lua" ? "lua-main.lua" : "Gameplay.cpp");
const auto module = read_text(source);
const auto header = language == "cpp"
? read_text(c.engine_root / "tools/project_templates/Gameplay.hpp")
: std::string();
const auto lua_annotations = language == "lua"
? read_text(c.engine_root / "tools/lua/faset.lua")
: std::string();
const auto lua_config = language == "lua"
? read_json(c.engine_root / "tools/lua/luarc.json")
: Json();
const auto lua_scripts_config =
language == "lua" ? read_json(c.engine_root / "tools/lua/luarc-scripts.json") : Json();
const auto stage = lock.path / "staged";
fs::create_directories(stage / "Scripts");
fs::create_directories(stage / "Scenes");
fs::create_directories(stage / "Assets");
if (language == "cpp") {
atomic_write(stage / "Scripts/Gameplay.cpp", module);
atomic_write(stage / "Scripts/Gameplay.hpp", header);
} else {
atomic_write(stage / "Scripts/main.lua", module);
atomic_write(stage / ".faset/lua/faset.lua", lua_annotations);
atomic_write_json(stage / ".luarc.json", lua_config);
atomic_write_json(stage / "Scripts/.luarc.json", lua_scripts_config);
}
atomic_write_json(stage / "Scenes/main.scene.json", scene);
Json project = {{"format", "faset.project"},
{"version", 1},
{"id", new_id()},
{"name", name},
{"dimension", dimension},
{"start_scene", "Scenes/main.scene.json"}};
if (language == "lua")
project["scripting"] = {{"lua", {{"scripts", Json::array({"Scripts/main.lua"})}}}};
atomic_write_json(stage / "project.faset.json", project);
atomic_write(stage / ".gitignore",
".faset/*\n!.faset/lua/\n.faset/lua/*\n!.faset/lua/faset.lua\nExports/\n");
std::vector<std::pair<fs::path, std::string>> created;
auto publish = [&](const fs::path& relative) {
const auto source = stage / relative;
const auto target = c.project_root / relative;
fs::create_directories(target.parent_path());
if (!fs::copy_file(source, target, fs::copy_options::none))
throw std::runtime_error("Starter destination appeared during creation: " +
path_to_utf8(target));
created.emplace_back(target, sha256_file(source));
};
try {
publish(".gitignore");
if (language == "cpp") {
publish("Scripts/Gameplay.cpp");
publish("Scripts/Gameplay.hpp");
} else {
publish("Scripts/main.lua");
publish(".faset/lua/faset.lua");
publish(".luarc.json");
publish("Scripts/.luarc.json");
}
publish("Scenes/main.scene.json");
fs::create_directory(c.project_root / "Assets");
// The manifest is the final commit marker: an interrupted create has no
// valid project record and never replaces an existing project file.
publish("project.faset.json");
} catch (...) {
for (auto it = created.rbegin(); it != created.rend(); ++it) {
try {
std::error_code ignored;
if (fs::is_regular_file(it->first, ignored) &&
sha256_file(it->first) == it->second)
fs::remove(it->first, ignored);
} catch (...) { /* Preserve the original create failure. */
}
}
for (const auto& relative : {"Assets", "Scenes", "Scripts", ".faset/lua"}) {
std::error_code ignored;
fs::remove(c.project_root / relative, ignored); // Empty directories only.
}
throw;
}
}
std::string BuildService::start_build() {
return impl_->enqueue("build");
}
+6 -2
View File
@@ -103,9 +103,13 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
add("faset_document_save",
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
object_schema({{"document", text}, {"path", text},
{"expected_revision", integer}}, {"document"}), [&](const Json& args) {
std::optional<std::uint64_t> expected;
if (args.contains("expected_revision"))
expected = args.at("expected_revision").get<std::uint64_t>();
return authoring_.save(args.at("document"),
path_from_utf8(args.value("path", std::string())));
path_from_utf8(args.value("path", std::string())), expected);
});
add(
"faset_schema",
+155 -6
View File
@@ -168,7 +168,7 @@ struct EditorUI::Impl {
Json instance_selection = Json::array(), template_conflicts = Json::array();
std::string document, selected, source_file, asset_filter,
active_bottom = "assets", menu, command_name = "faset_documents", status = "Ready",
gizmo_mode = "Move";
gizmo_mode = "Move", expanded_job_log;
Json current, resolved, files = Json::array(), assets = Json::array(), schemas = Json::object(),
recovery = Json::array();
std::uint64_t shown_revision = std::numeric_limits<std::uint64_t>::max();
@@ -186,6 +186,7 @@ struct EditorUI::Impl {
bool project_settings_open = false;
Json project_settings_state;
int project_settings_dimension = 3;
bool project_settings_autosave = true;
std::string project_settings_error;
bool project_switch_enabled = true, project_switch_requested = false,
project_switch_warning = false;
@@ -601,6 +602,20 @@ struct EditorUI::Impl {
schema_status.visible = false;
schema_status.enabled = false;
schema_status.tooltip = "Choose Build to refresh gameplay fields in Inspector";
auto& autosave_status = statusrow.add(Kind::Label, "autosave-status", "Saved");
autosave_status.layout.width = 160;
autosave_status.enabled = false;
auto& autosave_action = button(statusrow, "autosave-save-as", "Save As...", [this] {
const auto path = current.value("path", std::string());
const auto directory = path.empty() ? std::string("Scenes/")
: path.substr(0, path.find_last_of('/') + 1);
ui.update_text("save-path",
directory + "Recovered-" + new_id().substr(0, 8) + ".scene.json",
true);
menu = "File";
}, 100);
autosave_action.visible = false;
autosave_action.tooltip = "Save this scene to a new project-relative path";
label(statusrow, "renderer-status", "Vulkan", 225);
statusrow.find("renderer-status")->enabled = false;
build_overlays();
@@ -792,12 +807,20 @@ struct EditorUI::Impl {
}
label(project, "project-settings-start-label", "Start scene (saved project-relative path)");
project.add(Kind::TextField, "project-settings-start");
auto& autosave = project.add(Kind::Checkbox, "project-settings-autosave",
"Autosave named scenes after 2 seconds idle");
autosave.layout.height = 30;
autosave.checked = true;
autosave.on_commit = [this](Widget& widget) {
project_settings_autosave = widget.checked;
};
autosave.tooltip = "Unsaved scenes remain in recovery until you choose Save As";
auto& scenes = project.add(Kind::Column, "project-settings-scenes");
scenes.layout.height = 104;
scenes.layout.scroll = true;
scenes.layout.gap = 1;
label(project, "project-settings-note",
"Applies on next project open. Scene Undo is unchanged.");
"Autosave applies now; other settings on next open. Scene Undo is unchanged.");
auto& project_actions = project.add(Kind::Row, "project-settings-actions");
project_actions.layout.height = 32;
button(
@@ -1258,6 +1281,42 @@ struct EditorUI::Impl {
(current.value("dirty", false) ? " *" : "");
ui.find("project-title")->tooltip = project_name + " / " + scene_name;
ui.find("status")->text = status;
const auto autosave = call("faset_autosave_status");
std::string save_state = "saved", save_error;
if (!autosave.is_null()) {
if (!autosave.value("enabled", true))
save_state = "disabled";
for (const auto& entry : autosave.at("documents"))
if (entry.at("id") == document) {
save_state = entry.at("state").get<std::string>();
save_error = entry.value("error", std::string());
break;
}
if (!autosave.value("enabled", true) && save_state == "saved")
save_state = "disabled";
}
auto& save_label = *ui.find("autosave-status");
save_label.text = save_state == "pending" ? "Pending autosave"
: save_state == "saving" ? "Saving"
: save_state == "conflict" ? "Save conflict"
: save_state == "failed" ? "Save failed"
: save_state == "save_as_required" ? "Save As required"
: save_state == "disabled" ? "Autosave off"
: "Saved";
save_label.tooltip = save_state == "conflict"
? "Scene file changed outside Faset. Use Save As to keep both "
"versions, then compare or reload. " + save_error
: save_state == "failed"
? "Scene save failed. Your recovery journal remains available. "
"Use Save As or correct the error. " + save_error
: save_state == "save_as_required"
? "Choose Save As; unnamed scenes remain in recovery only"
: save_state == "disabled"
? "Autosave is off in Project settings; use Save manually"
: "Scene autosave status";
ui.find("autosave-save-as")->visible =
save_state == "conflict" || save_state == "failed" ||
save_state == "save_as_required";
ui.find("renderer-status")->text =
"Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects";
}
@@ -2043,6 +2102,45 @@ struct EditorUI::Impl {
trim_children(conflict_list, conflict_keep);
auto& console = *ui.find("console-items");
std::set<std::string> keep;
std::size_t shown_diagnostics{};
for (const auto& job : all_jobs) {
const auto id = job.at("id").get<std::string>();
const auto diagnostics = job.value("diagnostics", Json::array());
if (!diagnostics.is_array())
continue;
for (std::size_t index = 0; index < diagnostics.size() && shown_diagnostics < 60;
++index, ++shown_diagnostics) {
const auto& diagnostic = diagnostics[index];
const auto row_id = "diagnostic-" + id + "-" + std::to_string(index);
auto& row = console.add(Kind::Row, row_id);
row.layout.height = 27;
row.layout.gap = 8;
keep.insert(row_id);
const auto severity = diagnostic.value("severity", std::string("error"));
label(row, row_id + "-severity", severity, 68);
auto message = diagnostic.value("message", std::string());
auto& detail = row.add(Kind::Label, row_id + "-message", message);
detail.layout.flex = 1;
detail.tooltip = diagnostic.value("phase", std::string()) + ": " + message;
const auto file = diagnostic.value("file", std::string());
const auto line = diagnostic.value("line", 1);
const auto column = diagnostic.value("column", 1);
const auto location = file.empty()
? std::string("—")
: file + ":" + std::to_string(line) + ":" +
std::to_string(column);
label(row, row_id + "-location", location, 235);
auto& open = button(
row, row_id + "-open", "Open source",
[this, file, line, column] {
call("faset_source_open",
{{"path", file}, {"line", line}, {"column", column}});
},
115);
open.enabled = !file.empty();
open.appearance = ui::Appearance::Quiet;
}
}
const auto& logs = session.logs();
for (std::size_t i = logs.size() > 150 ? logs.size() - 150 : 0; i < logs.size(); ++i) {
std::string line = logs[i];
@@ -2065,11 +2163,25 @@ struct EditorUI::Impl {
row.layout.height = 28;
keep.insert(row.id);
const auto state = job.value("state", std::string());
const auto result = job.value("result", Json::object());
std::string reuse;
if (result.is_object() && result.value("schema_cache_hit", false))
reuse = " · verified reuse";
if (result.is_object() && result.contains("phase_times_ms") &&
result.at("phase_times_ms").is_object())
reuse += " · " +
std::to_string(result.at("phase_times_ms").value("total", 0)) + " ms";
auto& text = row.add(Kind::Label, "job-text-" + id,
job.value("kind", std::string("Job")) + " · " + state + " · " +
job.value("stage", std::string()) + " " +
std::to_string(int(job.value("progress", 0.0) * 100)) + "%");
std::to_string(int(job.value("progress", 0.0) * 100)) + "%" +
reuse);
text.layout.flex = 1;
auto& log_button = button(
row, "job-log-toggle-" + id,
expanded_job_log == id ? "Hide log" : "View log",
[this, id] { expanded_job_log = expanded_job_log == id ? "" : id; }, 86);
log_button.appearance = ui::Appearance::Quiet;
auto& cancel = button(
row, "job-cancel-" + id, "Cancel",
[this, id] { call("faset_job_cancel", {{"id", id}}); }, 72);
@@ -2078,6 +2190,38 @@ struct EditorUI::Impl {
row, "job-review-" + id, "Review removals", [this] { active_bottom = "conflicts"; },
135);
review.visible = job.value("kind", std::string()) == "import" && state == "conflict";
if (expanded_job_log == id) {
const auto panel_id = "job-log-panel-" + id;
auto& panel = jobs.add(Kind::Column, panel_id);
panel.layout.height = 170;
panel.layout.scroll = true;
panel.layout.gap = 1;
keep.insert(panel_id);
std::set<std::string> log_keep;
const auto raw = job.value("log", std::string());
auto& copy = button(panel, panel_id + "-copy", "Copy full log",
[this, raw] { renderer.set_clipboard(raw); }, 110);
copy.appearance = ui::Appearance::Quiet;
log_keep.insert(copy.id);
std::vector<std::string> lines;
std::size_t begin{};
while (begin < raw.size()) {
const auto end = raw.find('\n', begin);
lines.push_back(raw.substr(begin, end == std::string::npos
? std::string::npos
: end - begin));
if (end == std::string::npos)
break;
begin = end + 1;
}
const auto first = lines.size() > 100 ? lines.size() - 100 : 0;
for (std::size_t index = first; index < lines.size(); ++index) {
const auto line_id = panel_id + "-line-" + std::to_string(index);
label(panel, line_id, lines[index].substr(0, 600));
log_keep.insert(line_id);
}
trim_children(panel, log_keep);
}
}
if (keep.empty()) {
label(jobs, "jobs-empty", "No active import, build or export jobs.");
@@ -2094,7 +2238,7 @@ struct EditorUI::Impl {
refresh_project_settings();
const bool modal = palette || !recovery.empty() || simulation_open ||
project_switch_warning || project_settings_open;
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel"})
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel", "statusbar"})
ui.find(id)->enabled = !modal;
const bool file = menu == "File" || menu == "Faset",
edit = menu == "Edit" || menu == "Scene",
@@ -2149,6 +2293,8 @@ struct EditorUI::Impl {
project_settings_state = result;
const auto& settings = result.at("settings");
project_settings_dimension = settings.value("dimension", 3);
project_settings_autosave = settings.at("editor").at("autosave").get<bool>();
ui.find("project-settings-autosave")->checked = project_settings_autosave;
ui.update_text("project-settings-name", settings.value("name", std::string()), true);
ui.update_text("project-settings-start", settings.value("start_scene", std::string()),
true);
@@ -2215,7 +2361,9 @@ struct EditorUI::Impl {
project_settings_error = "Enter a project name.";
return;
}
Json changes = {{"name", name}, {"dimension", project_settings_dimension}};
Json changes = {{"name", name},
{"dimension", project_settings_dimension},
{"editor", {{"autosave", project_settings_autosave}}}};
if (!start.empty() ||
!project_settings_state.at("settings").value("start_scene", std::string()).empty())
changes["start_scene"] = start;
@@ -2229,7 +2377,7 @@ struct EditorUI::Impl {
project_settings_state = result;
project_settings_open = false;
project_settings_error.clear();
status = "Project settings saved for the next project open";
status = "Project settings saved; autosave preference applies now";
}
void refresh_project_settings() {
auto* panel = ui.find("project-settings-panel");
@@ -2240,6 +2388,7 @@ struct EditorUI::Impl {
panel->layout.y = std::max(0.f, (logical_height() - panel->layout.height) * .5f);
ui.find("project-settings-2d")->selected = project_settings_dimension == 2;
ui.find("project-settings-3d")->selected = project_settings_dimension == 3;
ui.find("project-settings-autosave")->checked = project_settings_autosave;
ui.find("project-settings-error")->text = project_settings_error;
for (auto& choice : ui.find("project-settings-scenes")->children)
if (choice->kind == Kind::TreeRow)
+184 -93
View File
@@ -2,6 +2,7 @@
#include <chrono>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/session.hpp>
#include <faset/scripting/project.hpp>
@@ -22,6 +23,52 @@ BuildConfig build_config(const SessionConfig& config) {
result.cache_root = config.project_root / ".faset/cache";
return result;
}
struct ProjectState {
Json value;
std::string revision;
};
ProjectState read_project_state(const std::filesystem::path& project_root) {
const auto path = project_root / "project.faset.json";
if (std::filesystem::exists(path)) {
auto value = read_json(path);
// The revision represents persisted content, including whether a
// default-valued setting was explicitly written by another client.
const auto revision = sha256(value.dump());
require(value.is_object() && value.value("format", "") == "faset.project" &&
value.value("version", 0) == 1,
"project.version", "Unsupported project format or version");
require(value.contains("name") && value.at("name").is_string() &&
!value.at("name").get<std::string>().empty(),
"project.name", "Project name must be a nonempty string");
const auto dimension = value.value("dimension", 3);
require(dimension == 2 || dimension == 3, "project.dimension",
"Project dimension must be 2 or 3");
if (value.contains("start_scene")) {
require(value.at("start_scene").is_string(), "project.start_scene",
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(project_root, path_from_utf8(scene));
}
if (value.contains("editor"))
require(value.at("editor").is_object(), "project.editor",
"Project editor settings must be an object");
else
value["editor"] = Json::object();
if (value["editor"].contains("autosave"))
require(value["editor"]["autosave"].is_boolean(), "project.autosave",
"Project autosave setting must be true or false");
else
value["editor"]["autosave"] = true;
return {std::move(value), revision};
}
Json value = {{"format", "faset.project"},
{"version", 1},
{"name", path_to_utf8(project_root.filename())},
{"dimension", 3},
{"editor", {{"autosave", true}}}};
return {value, sha256(value.dump())};
}
Json resolved_or_throw(Commands& commands, const std::string& id) {
const auto resolved = commands.resolved_scene(id);
if (!resolved.at("conflicts").empty())
@@ -51,7 +98,13 @@ struct Session::ImportTask {
};
Session::Session(SessionConfig config)
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)),
autosave_([this](const std::string& document, std::uint64_t revision) {
return authoring_.save(document, {}, revision);
}) {
autosave_enabled_ = project().at("editor").at("autosave").get<bool>();
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
autosave_enabled_);
register_commands();
plugins_ = std::make_unique<PluginManager>(
commands_, [this](std::string message) { log(std::move(message)); });
@@ -82,36 +135,16 @@ void Session::log(std::string value) {
logs_.erase(logs_.begin(), logs_.begin() + 100);
}
Json Session::project() const {
const auto path = config_.project_root / "project.faset.json";
if (std::filesystem::exists(path)) {
const auto value = read_json(path);
require(value.is_object() && value.value("format", "") == "faset.project" &&
value.value("version", 0) == 1,
"project.version", "Unsupported project format or version");
require(value.contains("name") && value.at("name").is_string() &&
!value.at("name").get<std::string>().empty(),
"project.name", "Project name must be a nonempty string");
const auto dimension = value.value("dimension", 3);
require(dimension == 2 || dimension == 3, "project.dimension",
"Project dimension must be 2 or 3");
if (value.contains("start_scene")) {
require(value.at("start_scene").is_string(), "project.start_scene",
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(config_.project_root, path_from_utf8(scene));
}
return value;
}
return {{"format", "faset.project"},
{"version", 1},
{"name", path_to_utf8(config_.project_root.filename())},
{"dimension", 3}};
return read_project_state(config_.project_root).value;
}
void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension);
log("Created project: " + name);
}
void Session::scaffold(const std::string& name, int dimension, std::string_view language) {
builds_.scaffold(name, dimension, language);
log("Created " + std::string(language) + " starter project: " + name);
}
Json Session::assets_list() const {
Json list = Json::array();
const auto directory = assets_.cache_root() / "assets";
@@ -132,20 +165,7 @@ Json Session::assets_list() const {
}
std::string Session::source_signature() const {
const auto lua = scripting::loadLuaProject(config_.project_root);
const auto directory = config_.project_root / "Scripts";
std::vector<std::filesystem::path> files;
if (std::filesystem::exists(directory))
for (const auto& file : std::filesystem::recursive_directory_iterator(directory))
if (file.is_regular_file() && (!lua.enabled() || file.path().extension() != ".lua"))
files.push_back(file.path());
std::sort(files.begin(), files.end());
std::string contents;
for (const auto& file : files)
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
sha256_file(file) + "\n";
if (lua.enabled())
contents += "lua:" + lua.fingerprint + "\n";
return sha256(contents);
return gameplay_source_hash(config_.project_root, lua);
}
Json Session::schema_status() const {
try {
@@ -305,12 +325,19 @@ void Session::poll() {
if (status == "failed" || status == "conflict")
log("Asset import " + status + ": " + value.value("error", std::string()));
}
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
autosave_enabled_);
}
void Session::register_commands() {
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
auto schema = [](Json properties, Json required = Json::array()) {
return Commands::object_schema(std::move(properties), std::move(required));
};
commands_.add("faset_autosave_status",
"Read revision-aware scene autosave state. Unnamed scenes remain in recovery "
"until explicitly saved with a path.",
schema(Json::object()),
[this](const Json&) { return autosave_.status(); }, true);
commands_.add(
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
schema(Json::object()),
@@ -344,23 +371,25 @@ void Session::register_commands() {
"faset_project_settings_get", "Read project settings and their content revision.",
schema(Json::object()),
[&](const Json&) {
const auto value = project();
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
const auto state = read_project_state(config_.project_root);
return Json{{"settings", state.value}, {"revision", state.revision}};
},
true);
commands_.add(
"faset_project_settings_set",
"Save project name, initial scene dimension or start scene with an expected content "
"revision. Applies on the next project open; does not change the active scene or its Undo "
"history.",
"Save project name, initial scene dimension, start scene or editor.autosave with an "
"expected content revision. Autosave applies immediately; other settings apply on the "
"next project open without changing scene Undo history.",
schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}),
[&](const Json& args) {
auto value = project();
require(args.at("revision") == sha256(value.dump()), "revision.conflict",
auto state = read_project_state(config_.project_root);
auto value = std::move(state.value);
require(args.at("revision") == state.revision, "revision.conflict",
"Project settings changed; reload them before saving");
const auto& changes = args.at("settings");
for (const auto& [key, field] : changes.items()) {
require(key == "name" || key == "dimension" || key == "start_scene",
require(key == "name" || key == "dimension" || key == "start_scene" ||
key == "editor",
"project.setting", "Unknown editable project setting: " + key);
if (key == "name")
require(field.is_string() && !field.get<std::string>().empty(), "project.name",
@@ -368,7 +397,15 @@ void Session::register_commands() {
else if (key == "dimension")
require(field.is_number_integer() && (field == 2 || field == 3),
"project.dimension", "Initial scene dimension must be 2 or 3");
else {
else if (key == "editor") {
require(field.is_object() && field.size() == 1 &&
field.contains("autosave"),
"project.setting", "Only editor.autosave can be changed here");
require(field.at("autosave").is_boolean(), "project.autosave",
"Autosave must be enabled or disabled");
value["editor"]["autosave"] = field.at("autosave");
continue;
} else {
require(field.is_string() && !field.get<std::string>().empty(),
"project.start_scene", "Choose a saved scene inside the project");
const auto file = project_path(config_.project_root,
@@ -386,7 +423,10 @@ void Session::register_commands() {
if (!value.contains("id"))
value["id"] = new_id();
atomic_write_json(config_.project_root / "project.faset.json", value);
log("Project settings saved; changes apply on next project open");
autosave_enabled_ = value.at("editor").at("autosave").get<bool>();
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
autosave_enabled_);
log("Project settings saved; autosave preference applies immediately");
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
});
commands_.add(
@@ -514,51 +554,102 @@ void Session::register_commands() {
{"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));
const auto open_source = [this](const Json& args, bool lua_only) -> Json {
const auto path_error = lua_only ? "lua.source" : "source.path";
const auto editor_error = lua_only ? "lua.editor" : "source.editor";
const auto requested = path_from_utf8(args.at("path").get<std::string>());
require(!requested.empty() && !requested.is_absolute(), path_error,
"Choose an existing source inside Scripts");
for (const auto& part : requested)
require(part != "..", path_error, "Source path cannot traverse outside Scripts");
std::filesystem::path file;
try {
file = project_path(config_.project_root, requested);
} catch (const std::exception&) {
throw Error(path_error, "Source path must stay inside project Scripts");
}
const auto project_root = std::filesystem::weakly_canonical(config_.project_root);
const auto relative = file.lexically_relative(project_root);
const auto name = generic_path_to_utf8(relative);
const auto extension = path_to_utf8(file.extension());
const bool supported = extension == ".cpp" || extension == ".hpp" ||
extension == ".h" || extension == ".cc" ||
extension == ".cxx" || extension == ".lua";
require(name.starts_with("Scripts/") && supported &&
(!lua_only || extension == ".lua") &&
std::filesystem::is_regular_file(file),
path_error, "Choose an existing source file inside Scripts");
const auto line = args.value("line", 1);
const auto column = args.value("column", 1);
require(line > 0 && column > 0, path_error, "Source line and column must be positive");
Json command = Json::array({"zed", "{file}:{line}:{column}"});
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(), editor_error,
"Configure editor.script_editor as a nonempty executable/argument array");
auto replace = [](std::string& target, std::string_view token,
const std::string& replacement) {
bool found = false;
std::size_t position = 0;
while ((position = target.find(token, position)) != std::string::npos) {
target.replace(position, token.size(), replacement);
position += replacement.size();
found = true;
}
require(!arguments.front().empty(), "lua.editor", "Choose an editor executable");
if (!has_file)
arguments.push_back(path_to_utf8(file));
return found;
};
std::vector<std::string> arguments;
bool has_file = false;
for (const auto& part : command) {
require(part.is_string(), editor_error, "Editor arguments must be strings");
auto argument = part.get<std::string>();
require(argument.find('\0') == std::string::npos, editor_error,
"Editor arguments cannot contain NUL");
if (arguments.empty())
require(!argument.empty() && argument.find('{') == std::string::npos,
editor_error, "The first argument must be an editor executable");
else {
has_file |= replace(argument, "{file}", path_to_utf8(file));
replace(argument, "{line}", std::to_string(line));
replace(argument, "{column}", std::to_string(column));
replace(argument, "{project}", path_to_utf8(project_root));
}
arguments.push_back(std::move(argument));
}
if (!has_file)
arguments.push_back(path_to_utf8(file));
try {
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")}};
});
} catch (const std::exception& error) {
throw Error(editor_error, "Could not launch external source editor",
{{"reason", error.what()}});
}
log("Opened source in external editor: " + name);
return {{"opened", name}, {"path", name}, {"line", line}, {"column", column}};
};
const auto source_schema = schema(
{{"path", text},
{"line", {{"type", "integer"}, {"minimum", 1}}},
{"column", {{"type", "integer"}, {"minimum", 1}}},
{"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}},
{"path"});
commands_.add("faset_source_open",
"Open a C++ or Lua source under Scripts at a one-based line and column. "
"The editor is an argv array; {file}, {line}, {column} and {project} are "
"substituted without a shell.",
source_schema, [open_source](const Json& args) {
return open_source(args, false);
});
commands_.add("faset_script_open",
"Open a project Lua source in an external editor. This compatibility command "
"retains Lua-only validation and uses the same safe source launcher.",
source_schema, [open_source](const Json& args) {
return open_source(args, true);
});
commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.",
+196
View File
@@ -0,0 +1,196 @@
#include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <iostream>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool condition, std::string_view message) {
if (!condition)
throw std::runtime_error(std::string(message));
}
void run(const fs::path& root) {
const auto project = root / "project";
const auto scripts = project / "Scripts";
atomic_write(scripts / "Gameplay.cpp", "#include \"Gameplay.hpp\"\n");
atomic_write(scripts / "Gameplay.hpp", "#pragma once\n");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 1\n");
const auto compiler = root / "fake-compiler";
const auto cmake = root / "fake-cmake";
const auto slang = root / "fake-slangc";
atomic_write(compiler, "compiler-v1\n");
atomic_write(cmake, "cmake-v1\n");
atomic_write(slang, "slang-v1\n");
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
config.build_directory = project / ".faset/build";
config.cmake = path_to_utf8(cmake);
config.configure_arguments = {
"-DCMAKE_C_COMPILER=" + path_to_utf8(compiler),
"-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler),
"-DSLANGC_EXECUTABLE=" + path_to_utf8(slang)};
const scripting::LuaProject none;
const auto initial = editor::capture_build_inputs(config, none);
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
const auto changed_header = editor::capture_build_inputs(config, none);
check(initial.source_hash != changed_header.source_hash &&
initial.fingerprint() != changed_header.fingerprint(),
"Nested gameplay header changes the complete source snapshot");
atomic_write(scripts / "main.lua", "return {}\n");
atomic_write_json(project / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"scripting", {{"lua", {{"scripts", {"Scripts/main.lua"}}}}}}});
auto lua = scripting::loadLuaProject(project);
const auto with_lua = editor::capture_build_inputs(config, lua);
atomic_write(scripts / "other.lua", "return {}\n");
auto manifest = read_json(project / "project.faset.json");
manifest["scripting"]["lua"]["scripts"].push_back("Scripts/other.lua");
atomic_write_json(project / "project.faset.json", manifest);
lua = scripting::loadLuaProject(project);
const auto changed_declaration = editor::capture_build_inputs(config, lua);
check(with_lua.source_hash != changed_declaration.source_hash,
"Lua entry declaration changes the source snapshot");
const auto native = config.build_directory / "Debug";
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
atomic_write(native / "sentinel", "keep\n");
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
check(fs::exists(native / "sentinel"), "Unchanged toolchain preserves the native tree");
atomic_write(compiler, "compiler-v2\n");
const auto changed_tool = editor::capture_build_inputs(config, lua);
check(changed_tool.source_hash == changed_declaration.source_hash &&
changed_tool.toolchain_hash != changed_declaration.toolchain_hash,
"Changing compiler bytes at the same path changes toolchain identity");
editor::ensure_native_toolchain_stamp(config, native, changed_tool);
check(!fs::exists(native / "sentinel"),
"Changed toolchain invalidates only the generated native tree");
const auto user_directory = root / "important-user-data";
atomic_write(user_directory / "do-not-delete.txt", "valuable\n");
bool refused_unowned{};
try {
editor::ensure_native_toolchain_stamp(config, user_directory, changed_tool);
} catch (const std::exception&) {
refused_unowned = true;
}
check(refused_unowned && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Unowned native directory is never cleared");
std::error_code native_link_error;
fs::create_directory_symlink(user_directory, config.build_directory / "Release",
native_link_error);
if (!native_link_error) {
bool refused_link{};
try {
editor::ensure_native_toolchain_stamp(config, config.build_directory / "Release",
changed_tool);
} catch (const std::exception&) {
refused_link = true;
}
check(refused_link && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Symlinked native directory cannot redirect cleanup into user data");
}
const auto relative_tool = project / "tools/cmake-fixture";
atomic_write(relative_tool, "relative-cmake-v1\n");
auto relative_config = config;
relative_config.cmake = "tools/cmake-fixture";
const auto relative_before = editor::capture_build_inputs(relative_config, lua);
atomic_write(relative_tool, "relative-cmake-v2\n");
check(relative_before.toolchain_hash !=
editor::capture_build_inputs(relative_config, lua).toolchain_hash,
"Relative CMake executable resolves from the project working directory");
auto typed_config = config;
const auto typed_compiler = root / "typed-compiler";
atomic_write(typed_compiler, "typed-v1\n");
typed_config.configure_arguments = {
"-DCMAKE_C_COMPILER:FILEPATH=" + path_to_utf8(typed_compiler)};
const auto typed_before = editor::capture_build_inputs(typed_config, lua);
atomic_write(typed_compiler, "typed-v2\n");
check(typed_before.toolchain_hash !=
editor::capture_build_inputs(typed_config, lua).toolchain_hash,
"Typed CMake compiler overrides are included in toolchain identity");
auto malicious_config = config;
malicious_config.configure_arguments.push_back(
"-DFASET_GAMEPLAY_SOURCE_DIR:PATH=" + path_to_utf8(root / "outside"));
bool refused_source_override{};
try {
(void)editor::capture_build_inputs(malicious_config, lua);
} catch (const std::exception&) {
refused_source_override = true;
}
check(refused_source_override,
"Configure arguments cannot redirect gameplay compilation away from hashed Scripts");
const auto staged = editor::stage_gameplay_sources(config, changed_tool, lua,
"snapshot-fixture");
check(staged != scripts && fs::is_regular_file(staged / "Gameplay.cpp") &&
read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"Native gameplay compilation uses a complete staged Scripts snapshot");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 999\n");
check(read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"A live edit cannot alter the source bytes of an in-flight native build");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
check(editor::stage_gameplay_sources(config, changed_tool, lua, "snapshot-again") ==
staged,
"Identical sources retain a stable native path for incremental Ninja builds");
atomic_write(native / "faset_player", "player-v1\n");
atomic_write(native / "faset_schema_exporter", "exporter-v1\n");
atomic_write(native / "CMakeCache.txt", "recipe-v1\n");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v1\n");
auto package_key = [&] (const editor::BuildInputs& inputs) {
return editor::build_package_key(inputs, native, "Debug", native / "faset_player",
native / "faset_schema_exporter");
};
const auto first_key = package_key(changed_tool);
check(first_key != package_key(changed_declaration) &&
first_key != package_key(with_lua),
"Toolchain and Lua source changes invalidate package identity");
auto option_config = config;
option_config.configure_arguments.push_back("-DFASET_TEST_OPTION=ON");
check(first_key != package_key(editor::capture_build_inputs(option_config, lua)),
"Configure option changes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v2\n");
check(first_key != package_key(changed_tool),
"Shader bytes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v2\n");
check(first_key != package_key(changed_tool),
"Runtime DLL bytes invalidate package identity");
fs::create_directories(root / "outside");
std::error_code link_error;
fs::create_directory_symlink(root / "outside", scripts / "linked", link_error);
if (!link_error) {
bool rejected = false;
try {
(void)editor::capture_build_inputs(config, lua);
} catch (const std::exception&) {
rejected = true;
}
check(rejected, "Source snapshot refuses a symlink escaping Scripts");
}
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / path_from_utf8("Faset build cache Café 世界 " + new_id());
try {
run(root);
fs::remove_all(root);
std::cout << "Source, toolchain and native package identity contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
fs::remove_all(root);
return 1;
}
}
+76
View File
@@ -0,0 +1,76 @@
#include <faset/editor/build_diagnostics.hpp>
#include <faset/core/io.hpp>
#include <iostream>
#include <stdexcept>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool condition, std::string_view message) {
if (!condition)
throw std::runtime_error(std::string(message));
}
void contracts() {
const auto project = fs::path("/project");
auto rows = editor::parse_build_diagnostics(
"/project/Scripts/Game.cpp:17:4: error: bad field\n", "compile", project);
check(rows.size() == 1 && rows[0].at("file") == "Scripts/Game.cpp" &&
rows[0].at("line") == 17 && rows[0].at("column") == 4 &&
rows[0].at("severity") == "error" && rows[0].at("phase") == "compile",
"Clang error has a project-relative source location");
rows = editor::parse_build_diagnostics(
"\x1b[31mScripts/player.lua:6: unexpected symbol near '='\x1b[0m\n",
"schema", project);
check(rows.size() == 1 && rows[0].at("file") == "Scripts/player.lua" &&
rows[0].at("line") == 6 && rows[0].at("severity") == "error" &&
rows[0].at("message") == "unexpected symbol near '='",
"Lua syntax error and ANSI stripping work");
const auto windows = path_from_utf8(R"(C:\Café)");
rows = editor::parse_build_diagnostics(
R"(C:\Café\Scripts\Game.cpp(17,4): error C2143: syntax error)" "\n"
R"(C:\Café\Scripts\Game.cpp:19:2: warning: suspicious conversion)" "\n",
"compile", windows);
check(rows.size() == 2 && rows[0].at("file") == "Scripts/Game.cpp" &&
rows[0].at("code") == "C2143" && rows[0].at("line") == 17 &&
rows[1].at("severity") == "warning" && rows[1].at("line") == 19,
"Windows drive colon and Unicode directory remain intact");
rows = editor::parse_build_diagnostics(
"Scripts/Game.cpp:9:3: error: missing value\n"
" broken call\n"
" ^~~~~~\n"
"Scripts/Game.cpp:4:1: note: declared here\n",
"compile", project);
check(rows.size() == 2 && rows[0].at("severity") == "error" &&
rows[1].at("severity") == "note" && rows[1].at("file") == "Scripts/Game.cpp",
"Caret and source excerpts do not become duplicate diagnostics");
rows = editor::parse_build_diagnostics(
"/outside/Scripts/Game.cpp:4:2: error: external\n"
"../Scripts/Escape.cpp:5:2: error: escaped\n",
"compile", project);
check(rows.size() == 2 && !rows[0].contains("file") && !rows[1].contains("file"),
"External and traversing sources are never navigable");
rows = editor::parse_build_diagnostics(
"Scripts/../Scripts/Game.cpp:4:2: error: disguised traversal\n"
"Scripts/Game.cpp:4:0: error: invalid column\n",
"compile", project);
check(rows.size() == 1 && !rows[0].contains("file"),
"Traversal stays non-navigable and zero columns are rejected");
std::string noisy;
for (int index = 0; index < 300; ++index)
noisy += "Scripts/Game.cpp:2:1: warning: repeated\n";
check(editor::parse_build_diagnostics(noisy, "compile", project).size() <= 200,
"Structured diagnostic count is bounded");
}
} // namespace
int main() {
try {
contracts();
std::cout << "Build diagnostic parsing contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+101 -3
View File
@@ -142,11 +142,72 @@ int test_main(int argc, char** argv) {
config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"};
editor::BuildService builds(config);
builds.scaffold("Schema publication", 2);
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 1\n");
authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas());
const auto valid = manifest();
atomic_write_json(config.project_root / "schema-fixture.json", valid);
const auto first = builds.wait(builds.start_build());
{
auto compiler_config = config;
compiler_config.project_root = root / "c-only-compiler-project";
compiler_config.configure_arguments = {
"-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only"};
editor::BuildService compiler_build(compiler_config);
compiler_build.scaffold("Compiler argument fixture", 2);
atomic_write_json(compiler_config.project_root / "schema-fixture.json", valid);
const auto compiler_result = compiler_build.wait(compiler_build.start_build());
check(compiler_result.state == "succeeded",
"C-only compiler override fixture builds: " + compiler_result.error);
const auto compiler_args =
read_json(compiler_config.project_root / "configure-fixture.json");
std::size_t c_overrides{};
bool cxx_default{};
for (const auto& arg : compiler_args) {
const auto text = arg.get<std::string>();
if (text.starts_with("-DCMAKE_C_COMPILER")) {
++c_overrides;
check(text == "-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only",
"Typed C override is not replaced by a default compiler");
}
if (text.starts_with("-DCMAKE_CXX_COMPILER="))
cxx_default = true;
}
check(c_overrides == 1 && cxx_default,
"C and C++ compiler defaults are selected independently");
}
auto first = builds.wait(builds.start_build());
check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error);
fs::path compiled_scripts;
for (const auto& arg : read_json(config.project_root / "configure-fixture.json")) {
const auto text = arg.get<std::string>();
constexpr std::string_view prefix = "-DFASET_GAMEPLAY_SOURCE_DIR=";
if (text.starts_with(prefix))
compiled_scripts = path_from_utf8(text.substr(prefix.size()));
}
check(!compiled_scripts.empty() &&
compiled_scripts != config.project_root / "Scripts" &&
read_text(compiled_scripts / "Extensions/BuildOnly.hpp") ==
"#define BUILD_ONLY 1\n",
"Native C++ build receives the immutable source snapshot");
const auto repeated = builds.wait(builds.start_build());
check(repeated.state == "succeeded" &&
repeated.result.at("generation") == first.result.at("generation") &&
repeated.result.at("schema_cache_hit") == true &&
repeated.result.at("generation_reused") == true &&
read_text(config.project_root / "schema-export-count.txt") == "1",
"Unchanged native build reuses a verified schema generation");
atomic_write(path_from_utf8(first.result.at("schema").get<std::string>()), "truncated");
first = builds.wait(builds.start_build());
check(first.state == "succeeded" && first.result.at("schema_cache_hit") == false &&
read_text(config.project_root / "schema-export-count.txt") == "2",
"Corrupt schema cannot be a cache hit");
atomic_write(path_from_utf8(first.result.at("directory").get<std::string>()) /
"shaders/vertexMain.spv",
"corrupt");
first = builds.wait(builds.start_build());
check(first.state == "succeeded" && first.result.at("schema_cache_hit") == false &&
read_text(config.project_root / "schema-export-count.txt") == "3",
"Corrupt shader cannot be a cache hit");
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
@@ -161,6 +222,34 @@ 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");
atomic_write(config.project_root / "emit-clang-error-and-fail-build", "fixture\n");
const auto compile_failed = builds.wait(builds.start_build());
check(compile_failed.state == "failed" &&
read_text(last_build) == previous_pointer &&
compile_failed.json().at("diagnostics").size() == 1 &&
compile_failed.json().at("diagnostics")[0].at("file") ==
"Scripts/Gameplay.cpp" &&
compile_failed.log.find("fixture compile failure") != std::string::npos,
"Failed compiler output retains raw log and structured source location");
fs::remove(config.project_root / "emit-clang-error-and-fail-build");
atomic_write(config.project_root / "fail-unparseable-build", "fixture\n");
const auto generic_failed = builds.wait(builds.start_build());
check(generic_failed.state == "failed" &&
generic_failed.json().at("diagnostics").size() == 1 &&
generic_failed.json().at("diagnostics")[0].at("severity") == "error" &&
!generic_failed.json().at("diagnostics")[0].contains("file") &&
read_text(last_build) == previous_pointer,
"Unparseable process failure has a non-navigable diagnostic");
fs::remove(config.project_root / "fail-unparseable-build");
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 3\n");
atomic_write(config.project_root / "mutate-cpp-header-during-build", "fixture\n");
const auto raced_header = builds.wait(builds.start_build());
check(raced_header.state == "failed" && read_text(last_build) == previous_pointer,
"Header changed during schema export cannot publish a mixed build");
fs::remove(config.project_root / "mutate-cpp-header-during-build");
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 1\n");
check(!first.result.at("lua_enabled").get<bool>() &&
!fs::exists(directory / "project.faset.json"),
"C++-only build publishes no Lua sources or project manifest");
@@ -169,6 +258,11 @@ int test_main(int argc, char** argv) {
"C++-only build explicitly disables the Lua VM in CMake");
authoring.replace_external_schemas(read_json(schema));
const auto previous_registry = authoring.schemas().manifest();
std::size_t published_generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
(void)entry;
++published_generations;
}
check(authoring.schemas().schema("game.mover").at("version") == 2,
"Matching custom v2 metadata reaches authoring");
migration_contracts(config.project_root / "migration-contracts", read_json(schema));
@@ -252,9 +346,11 @@ int test_main(int argc, char** argv) {
std::size_t generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
++generations;
check(entry.path() == directory, "Invalid build leaves no staging or generation");
check(!entry.path().filename().string().starts_with(".staging-"),
"Invalid build leaves no staging generation");
}
check(generations == 1, "Only the validated build generation remains");
check(generations == published_generations,
"Invalid build publishes no new generation");
bool rejected{};
try {
authoring.replace_external_schemas(invalid[index]);
@@ -305,6 +401,8 @@ int test_main(int argc, char** argv) {
"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 / "Scripts/main.lua",
std::string(lua_source) + "-- force a schema export\n");
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,
+16 -1
View File
@@ -8,6 +8,9 @@ using namespace faset;
int tool_main(int argc, char** argv) {
try {
if (argc >= 3 && std::string_view(argv[1]) == "--output") {
const auto count_file = fs::path("schema-export-count.txt");
const auto count = fs::exists(count_file) ? std::stoi(read_text(count_file)) : 0;
atomic_write(count_file, std::to_string(count + 1));
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");
@@ -21,6 +24,8 @@ int tool_main(int argc, char** argv) {
if (fs::exists("mutate-lua-snapshot"))
atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n");
}
if (fs::exists("mutate-cpp-header-during-build"))
atomic_write("Scripts/Extensions/BuildOnly.hpp", "#define BUILD_ONLY 2\n");
atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
return 0;
}
@@ -34,8 +39,18 @@ int tool_main(int argc, char** argv) {
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") {
if (fs::exists("emit-clang-error-and-fail-build")) {
std::cerr << path_to_utf8(fs::current_path() / "Scripts/Gameplay.cpp")
<< ":7:3: error: fixture compile failure\n";
return 1;
}
if (fs::exists("fail-unparseable-build")) {
std::cerr << "Synthetic native build failed without a source location\n";
return 1;
}
return 0;
}
fs::path build;
for (int i = 1; i + 1 < argc; ++i)
if (std::string_view(argv[i]) == "-B")
+101
View File
@@ -1,8 +1,12 @@
#include "assets_image_fixtures.hpp"
#include <algorithm>
#include <atomic>
#include <bit>
#include <chrono>
#include <cstdlib>
#include <functional>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
@@ -42,6 +46,102 @@ Json scene(int dimension) {
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}};
}
void starter_contracts(const fs::path& root) {
for (const int dimension : {2, 3}) {
for (const char* language : {"cpp", "lua"}) {
const auto project = root / path_from_utf8(std::string("Starter café ") + language +
std::to_string(dimension));
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService service(config);
service.scaffold("Starter", dimension, language);
const auto manifest = read_json(project / "project.faset.json");
const auto start = read_json(project / "Scenes/main.scene.json");
require(manifest.at("dimension") == dimension &&
manifest.at("start_scene") == "Scenes/main.scene.json",
"Explicit starter has the requested scene type and startup path");
require(start.at("dimension") == dimension && !start.at("entities").empty(),
"Starter scene is valid and has visible contents");
auto builtins = start;
bool has_behavior = false;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [&](const Json& component) {
const auto custom =
!component.at("type").get<std::string>().starts_with("faset.");
has_behavior |= custom;
return custom;
});
}
require(has_behavior, "Starter scene binds its gameplay behavior");
authoring::validate_scene(builtins, authoring::builtin_schemas());
if (std::string_view(language) == "lua") {
require(!fs::exists(project / "Scripts/Gameplay.cpp") &&
!fs::exists(project / "Scripts/Gameplay.hpp"),
"Lua starter has no C++ gameplay stub");
require(manifest.at("scripting").at("lua").at("scripts") ==
Json::array({"Scripts/main.lua"}) &&
scripting::loadLuaProject(project).enabled() &&
fs::is_regular_file(project / ".luarc.json") &&
fs::is_regular_file(project / "Scripts/.luarc.json") &&
fs::is_regular_file(project / ".faset/lua/faset.lua"),
"Lua starter declares an actual runnable module");
} else {
require(fs::is_regular_file(project / "Scripts/Gameplay.cpp") &&
fs::is_regular_file(project / "Scripts/Gameplay.hpp") &&
!manifest.contains("scripting"),
"C++ starter contains its compiled gameplay module");
}
const auto marker = read_text(project / "Scenes/main.scene.json");
bool rejected = false;
try {
service.scaffold("Again", dimension, language);
} catch (const std::exception&) {
rejected = true;
}
require(rejected && read_text(project / "Scenes/main.scene.json") == marker,
"Explicit creation rejects existing project without overwriting it");
}
}
editor::BuildConfig legacy;
legacy.project_root = root / "legacy";
legacy.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService(legacy).scaffold("Legacy", 3);
require(!fs::exists(legacy.project_root / "Scenes/main.scene.json"),
"Two-argument scaffold retains its prior no-scene behavior");
const auto concurrent_root = root / "concurrent";
editor::BuildConfig concurrent;
concurrent.project_root = concurrent_root;
concurrent.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService first(concurrent), second(concurrent);
std::atomic<int> ready = 0;
std::atomic<int> successes = 0;
auto attempt = [&](editor::BuildService& service, const char* name) {
++ready;
while (ready.load() < 2)
std::this_thread::yield();
try {
service.scaffold(name, 2, "lua");
++successes;
} catch (const std::exception&) {
}
};
std::thread a(attempt, std::ref(first), "First");
std::thread b(attempt, std::ref(second), "Second");
a.join();
b.join();
require(successes == 1, "Two concurrent creators publish exactly one starter");
const auto winner = read_json(concurrent_root / "project.faset.json");
require(winner.at("name") == "First" || winner.at("name") == "Second",
"Published manifest belongs to the successful creator");
require(fs::is_regular_file(concurrent_root / "Scripts/main.lua") &&
fs::is_regular_file(concurrent_root / "Scenes/main.scene.json"),
"The winning starter publishes all required files");
const auto ignore = read_text(concurrent_root / ".gitignore");
require(ignore.find("!.faset/lua/faset.lua") != std::string::npos,
"LuaLS declarations are commit-friendly in generated projects");
}
void lua_project_contracts(const fs::path& root) {
fs::create_directories(root);
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
@@ -379,6 +479,7 @@ int test_main(int argc, char** argv) {
try {
fs::create_directories(temporary);
lua_project_contracts(temporary / "lua-project");
starter_contracts(temporary / "starters");
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
const auto executable = temporary / original_executable.filename();
fs::copy_file(original_executable, executable);
+119
View File
@@ -0,0 +1,119 @@
#include <faset/authoring/service.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/autosave.hpp>
#include <chrono>
#include <iostream>
using namespace faset;
using namespace std::chrono_literals;
namespace fs = std::filesystem;
namespace {
void check(bool value, std::string_view message) {
if (!value)
throw std::runtime_error(std::string(message));
}
void contracts(const fs::path& root) {
authoring::AuthoringService service(root);
const auto created = service.create("Before", 2);
const auto id = created.at("id").get<std::string>();
const auto path = root / "Scenes/main.scene.json";
service.save(id, "Scenes/main.scene.json");
int saves{};
editor::AutosaveController autosave([&](const std::string& document, std::uint64_t revision) {
++saves;
return service.save(document, {}, revision);
});
using Clock = editor::AutosaveController::Clock;
const auto start = Clock::time_point{};
autosave.observe(service.documents(), start, true);
for (int index = 1; index <= 3; ++index) {
const auto revision = service.query(id).at("revision").get<std::uint64_t>();
service.transact(id, revision,
Json::array({{{"op", "scene.rename"},
{"name", "Edit " + std::to_string(index)}}}));
autosave.observe(service.documents(), start + index * 100ms, true);
}
const auto play_snapshot = service.query(id).at("scene");
autosave.observe(service.documents(), start + 2299ms, true);
check(saves == 0 && read_json(path).at("name") == "Before",
"Rapid edits coalesce until the last revision is idle for two seconds");
autosave.observe(service.documents(), start + 2301ms, true);
check(saves == 1 && read_json(path).at("name") == "Edit 3" &&
service.query(id).at("scene") == play_snapshot &&
autosave.status().at("documents")[0].at("state") == "saved",
"Named scene saves once without changing the Play snapshot");
const auto current_revision = service.query(id).at("revision").get<std::uint64_t>();
check(service.undo(id, current_revision).at("scene").at("name") == "Edit 2",
"Undo remains available after autosave");
bool stale_rejected{};
try {
service.save(id, {}, current_revision);
} catch (const Error& error) {
stale_rejected = error.code() == "revision.conflict";
}
check(stale_rejected, "A stale autosave revision cannot save a newer document");
const auto external = read_text(path) + " \n";
atomic_write(path, external);
const auto revision = service.query(id).at("revision").get<std::uint64_t>();
service.transact(id, revision,
Json::array({{{"op", "scene.rename"}, {"name", "After external edit"}}}));
autosave.observe(service.documents(), start + 3000ms, true);
autosave.observe(service.documents(), start + 5100ms, true);
check(saves == 2 && read_text(path) == external &&
autosave.status().at("documents")[0].at("state") == "conflict",
"Disk conflict does not overwrite external changes");
autosave.observe(service.documents(), start + 10000ms, true);
check(saves == 2, "A failed autosave does not retry every polling frame");
const auto unnamed = service.create("Unnamed", 2);
autosave.observe(service.documents(), start + 10100ms, true);
bool found_unnamed{};
const auto unnamed_status = autosave.status();
for (const auto& row : unnamed_status.at("documents"))
if (row.at("id") == unnamed.at("id"))
found_unnamed = row.at("state") == "save_as_required";
check(found_unnamed &&
fs::is_regular_file(root / ".faset/recovery" /
(unnamed.at("id").get<std::string>() + ".json")),
"Unnamed scene remains recoverable without receiving an implicit save path");
autosave.observe(service.documents(), start + 10200ms, false);
check(!autosave.status().at("enabled").get<bool>() && saves == 2,
"Disabled autosave leaves recovery journaling active");
int failed_attempts{};
editor::AutosaveController failing([&](const std::string&, std::uint64_t) -> Json {
++failed_attempts;
throw Error("io.write", "Synthetic read-only destination");
});
Json fake_documents = Json::array(
{{{"id", "failure-fixture"}, {"revision", 1},
{"path", "Scenes/fixture.scene.json"}, {"dirty", true}}});
failing.observe(fake_documents, start, true);
failing.observe(fake_documents, start + 2100ms, true);
check(failed_attempts == 1 &&
failing.status().at("documents")[0].at("state") == "failed",
"Write failures remain visible");
failing.observe(fake_documents, start + 10000ms, true);
check(failed_attempts == 1, "A failed write does not retry on every frame");
fake_documents[0]["revision"] = 2;
failing.observe(fake_documents, start + 10100ms, true);
failing.observe(fake_documents, start + 12200ms, true);
check(failed_attempts == 2, "A newer revision receives a fresh autosave deadline");
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / ("faset-autosave-" + new_id());
try {
contracts(root);
fs::remove_all(root);
std::cout << "Revision-aware autosave contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
fs::remove_all(root);
return 1;
}
}
+115 -4
View File
@@ -1,9 +1,18 @@
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
#include <chrono>
#include <iostream>
#include <thread>
int main() {
int test_main(int argc, char** argv) {
using namespace faset;
if (argc >= 3 && std::string_view(argv[1]) == "--editor-probe") {
Json arguments = Json::array();
for (int index = 3; index < argc; ++index)
arguments.push_back(argv[index]);
atomic_write_json(path_from_utf8(argv[2]), arguments);
return 0;
}
const auto root = std::filesystem::temp_directory_path() /
path_from_utf8("Faset Café 世界 session " + new_id());
try {
@@ -27,6 +36,8 @@ int main() {
require(!session.play_pending() && !session.playing(), "test",
"Stop must cancel a pending Play build");
const auto initial = commands.call("faset_project_settings_get", Json::object());
require(initial.at("settings").at("editor").at("autosave") == true, "test",
"Missing autosave setting must read as enabled");
auto changed = commands.call("faset_project_settings_set",
{{"revision", initial.at("revision")},
{"settings",
@@ -66,15 +77,57 @@ int main() {
atomic_write_json(root / "project.faset.json", external);
rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}},
"revision.conflict");
const auto reloaded = commands.call("faset_project_settings_get", Json::object());
auto reloaded = commands.call("faset_project_settings_get", Json::object());
auto explicit_default = external;
explicit_default["editor"]["autosave"] = true;
atomic_write_json(root / "project.faset.json", explicit_default);
rejects({{"revision", reloaded.at("revision")},
{"settings", {{"name", "Lost external write"}}}},
"revision.conflict");
reloaded = commands.call("faset_project_settings_get", Json::object());
changed =
commands.call("faset_project_settings_set", {{"revision", reloaded.at("revision")},
{"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"),
changed.at("settings").at("editor").at("script_editor") ==
external.at("editor").at("script_editor"),
"test", "Project settings erased Lua configuration or external editor command");
const auto autosave_off = commands.call(
"faset_project_settings_set",
{{"revision", changed.at("revision")},
{"settings", {{"editor", {{"autosave", false}}}}}});
require(autosave_off.at("settings").at("editor").at("autosave") == false &&
session.project().at("editor").at("script_editor") ==
external.at("editor").at("script_editor"),
"test", "Autosave setting must merge without erasing the editor command");
session.poll();
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == false,
"test", "Autosave disable must take effect in the current session");
{
editor::Session reopened({root, path_from_utf8(FASET_TEST_ENGINE), {}});
reopened.poll();
require(reopened.commands()
.call("faset_autosave_status", Json::object())
.at("enabled") == false,
"test", "Autosave setting must survive Editor reopen");
}
rejects({{"revision", changed.at("revision")},
{"settings", {{"editor", {{"autosave", true}}}}}},
"revision.conflict");
rejects({{"revision", autosave_off.at("revision")},
{"settings", {{"editor", {{"autosave", "sometimes"}}}}}},
"project.autosave");
rejects({{"revision", autosave_off.at("revision")},
{"settings", {{"editor", {{"script_editor", {"other"}}}}}}},
"project.setting");
changed = commands.call("faset_project_settings_set",
{{"revision", autosave_off.at("revision")},
{"settings", {{"editor", {{"autosave", true}}}}}});
session.poll();
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true,
"test", "Autosave enable must take effect in the current session");
const auto setup = commands.call("faset_lua_setup", Json::object());
require(setup.at("configuration_created") == true &&
setup.at("scripts_configuration_created") == true &&
@@ -115,6 +168,37 @@ int main() {
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
"lua.editor");
const auto probe = root / "editor-argv.json";
const auto opened = commands.call(
"faset_source_open",
{{"path", "Scripts/Gameplay.cpp"}, {"line", 17}, {"column", 4},
{"editor", Json::array({path_to_utf8(std::filesystem::absolute(path_from_utf8(argv[0]))),
"--editor-probe", path_to_utf8(probe),
"{file}:{line}:{column}", "{project}"})}});
for (int attempt = 0; attempt < 100 && !std::filesystem::exists(probe); ++attempt)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
const auto canonical_root = std::filesystem::weakly_canonical(root);
const auto expected_source = project_path(root, "Scripts/Gameplay.cpp");
const auto expected_arguments =
Json::array({path_to_utf8(expected_source) + ":17:4", path_to_utf8(canonical_root)});
const auto actual_arguments =
std::filesystem::is_regular_file(probe) ? read_json(probe) : Json(nullptr);
require(opened.at("path") == "Scripts/Gameplay.cpp" && opened.at("line") == 17 &&
actual_arguments == expected_arguments,
"test", "Source navigation passes a Unicode path and location as literal argv; "
"expected " + expected_arguments.dump() + ", got " +
actual_arguments.dump());
reject_command("faset_source_open", {{"path", "../outside.cpp"}}, "source.path");
reject_command("faset_source_open", {{"path", "Scripts/missing.cpp"}}, "source.path");
reject_command("faset_source_open", {{"path", "Scripts"}}, "source.path");
atomic_write(root / "Scripts/tool.exe", "not a source\n");
reject_command("faset_source_open", {{"path", "Scripts/tool.exe"}}, "source.path");
std::filesystem::create_directories(root / "Outside");
std::error_code link_error;
std::filesystem::create_symlink(root / "Outside/secret.cpp",
root / "Scripts/escape.cpp", link_error);
if (!link_error)
reject_command("faset_source_open", {{"path", "Scripts/escape.cpp"}}, "source.path");
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);
@@ -122,6 +206,24 @@ int main() {
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");
const auto autosave_id = document.at("id").get<std::string>();
const auto autosave_revision =
session.authoring().query(autosave_id).at("revision").get<std::uint64_t>();
session.authoring().transact(
autosave_id, autosave_revision,
Json::array({{{"op", "scene.rename"}, {"name", "Saved by MCP polling"}}}));
reject_command("faset_document_save",
{{"document", autosave_id},
{"expected_revision", autosave_revision}},
"revision.conflict");
session.poll();
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true,
"test", "Autosave status is available through Editor commands");
std::this_thread::sleep_for(std::chrono::milliseconds(2100));
session.poll();
require(read_json(root / path_from_utf8("Scenes/Начало 世界.scene.json")).at("name") ==
"Saved by MCP polling",
"test", "A long-lived headless Editor poll saves a named scene after idle");
external["version"] = 999;
atomic_write_json(root / "project.faset.json", external);
bool rejected = false;
@@ -136,7 +238,16 @@ int main() {
"configuration preservation and Lua editor commands passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << "\nFixture retained at " << root << '\n';
std::cerr << error.what() << "\nFixture retained at " << path_to_utf8(root) << '\n';
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
+70
View File
@@ -0,0 +1,70 @@
#include <chrono>
#include <cmath>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
#include <stdexcept>
int main(int argc, char** argv) {
using namespace faset;
using Clock = std::chrono::steady_clock;
if (argc != 2) {
std::cerr << "Usage: faset_editor_ui_latency OUTPUT.json\n";
return 2;
}
const auto root = std::filesystem::temp_directory_path() /
path_from_utf8("faset-ui-latency-" + new_id());
try {
std::filesystem::create_directories(root);
atomic_write_json(root / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"name", "UI latency fixture"},
{"dimension", 3}});
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
render::Renderer renderer({1280, 800, "Editor input latency", true, true});
editor::EditorUI ui(session, renderer,
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
ui.frame({});
Json samples = Json::array();
constexpr int warmup = 10, measured = 100;
for (int index = 0; index < warmup + measured; ++index) {
render::Event event;
event.type = render::Event::Type::KeyDown;
event.key = index % 2 == 0 ? "E" : "W";
const auto started = Clock::now();
ui.frame({event});
renderer.render(ui.snapshot());
const auto elapsed = std::chrono::duration<double, std::milli>(Clock::now() - started)
.count();
const auto target = index % 2 == 0 ? "gizmo-Rotate" : "gizmo-Move";
if (!ui.widgets().find(target)->selected ||
renderer.stats().validation_errors != 0 || !std::isfinite(elapsed) ||
elapsed <= 0)
throw std::runtime_error("Synthetic input was not visible in a valid frame");
if (index >= warmup)
samples.push_back({{"iteration", index - warmup + 1},
{"milliseconds", elapsed},
{"gizmo", event.key == "E" ? "Rotate" : "Move"}});
}
atomic_write_json(path_from_utf8(argv[1]),
{{"format", "faset.editor-ui-latency"},
{"version", 1},
{"warmup_frames", warmup},
{"measured_frames", measured},
{"presentation_mode", "offscreen"},
{"method", "KeyDown gizmo toggle through EditorUI::frame and "
"offscreen Vulkan render completion; no window present"},
{"device", renderer.stats().device},
{"validation_enabled", renderer.stats().validation_enabled},
{"validation_errors", renderer.stats().validation_errors},
{"samples", samples}});
std::filesystem::remove_all(root);
std::cout << "Measured " << measured << " synthetic Editor input frames\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << "\nFixture: " << path_to_utf8(root) << '\n';
return 1;
}
}
+17
View File
@@ -111,6 +111,9 @@ int main() {
text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename()));
click(launcher, "launcher-2d");
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
click(launcher, "launcher-lua");
check(launcher.widgets().find("launcher-lua")->selected,
"Lua project language selection");
renderer.render(launcher.snapshot());
renderer.capture(root / "launcher-create.ppm");
launcher.frame({key("Return", true)});
@@ -118,6 +121,7 @@ int main() {
"Create through keyboard rejected: " +
launcher.widgets().find("launcher-error")->text);
check(launcher.selection()->create && launcher.selection()->dimension == 2 &&
launcher.selection()->language == "lua" &&
launcher.selection()->name == "Тестовый проект",
"Create through keyboard preserves typed project metadata");
check(launcher.selection()->path == new_project,
@@ -127,6 +131,19 @@ int main() {
check(!std::filesystem::exists(new_project),
"Launcher does not create a partial project before Session scaffold");
}
{
editor::ProjectSelection retry{new_project, "Retry starter", 3, true, "lua"};
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
recents, retry, "Template source is unavailable");
launcher.frame({});
check(launcher.widgets().find("launcher-create")->selected &&
launcher.widgets().find("launcher-lua")->selected &&
launcher.widgets().find("launcher-path")->text == path_to_utf8(new_project) &&
launcher.widgets().find("launcher-name")->text == "Retry starter" &&
launcher.widgets().find("launcher-error")->text ==
"Template source is unavailable",
"Failed starter returns to Create with the chosen options and error");
}
{
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE),
root / "Missing", recents);
+71 -2
View File
@@ -1,6 +1,8 @@
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace faset;
namespace {
void check(bool value, const std::string& message) {
@@ -50,6 +52,7 @@ int main() {
{"id", new_id()},
{"name", "Original project"},
{"dimension", 3},
{"editor", {{"script_editor", {"zed", "{file}"}}}},
{"custom_metadata", {{"preserve", true}}}});
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
render::Renderer renderer({1280, 800, "Project settings acceptance", true, true});
@@ -73,6 +76,13 @@ int main() {
open(ui);
check(ui.widgets().find("project-settings-name")->text == "Original project",
"Opening settings reloads current saved values");
check(ui.widgets().find("project-settings-autosave")->checked,
"Missing autosave setting appears enabled in Project settings");
check(ui.widgets().focus("project-settings-autosave"),
"Keyboard focus reaches the Autosave toggle");
ui.frame({key("Space")});
check(!ui.widgets().find("project-settings-autosave")->checked,
"Space toggles Autosave from the keyboard");
text(ui, "project-settings-name", "Новый проект");
click(ui, "project-settings-2d");
click(ui, "project-scene-choice-1");
@@ -81,11 +91,25 @@ int main() {
click(ui, "project-settings-save");
auto saved = read_json(project_file);
check(saved["name"] == "Новый проект" && saved["dimension"] == 2 &&
saved["start_scene"] == "Scenes/Другая.scene.json",
saved["start_scene"] == "Scenes/Другая.scene.json" &&
saved["editor"]["autosave"] == false,
"Explicit Save project persists typed settings");
check(saved["custom_metadata"] == project_before["custom_metadata"] &&
saved["id"] == project_before["id"],
saved["id"] == project_before["id"] &&
saved["editor"]["script_editor"] ==
project_before["editor"]["script_editor"],
"Project settings preserve unknown metadata and project identity");
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Autosave off",
"Disabling Autosave is visible in the Editor status bar");
open(ui);
check(!ui.widgets().find("project-settings-autosave")->checked,
"Autosave preference is restored when reopening Project settings");
click(ui, "project-settings-autosave");
click(ui, "project-settings-save");
saved = read_json(project_file);
check(saved["editor"]["autosave"] == true,
"Autosave can be enabled again through Project settings");
check(session.authoring().query(ui.current_document()) == document_before,
"Project settings do not change current document, dimension, revision or Undo");
open(ui);
@@ -118,6 +142,51 @@ int main() {
"Save after explicit reload uses new content revision");
check(session.authoring().query(ui.current_document()) == document_before,
"Project settings remain outside scene Undo throughout conflicts");
const auto original_document = ui.current_document();
const auto revision =
session.authoring().query(original_document).at("revision").get<std::uint64_t>();
session.authoring().transact(
original_document, revision,
Json::array({{{"op", "scene.rename"}, {"name", "Pending autosave"}}}));
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Pending autosave",
"Status bar shows pending scene save after edit");
session.authoring().save(original_document);
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Saved",
"Status bar returns to Saved after the scene reaches disk");
session.authoring().transact(
original_document,
session.authoring().query(original_document).at("revision"),
Json::array({{{"op", "scene.rename"}, {"name", "Conflicting autosave"}}}));
ui.frame({});
auto disk_scene = read_json(root / "Scenes/Главная.scene.json");
disk_scene["name"] = "External scene edit";
atomic_write_json(root / "Scenes/Главная.scene.json", disk_scene);
std::this_thread::sleep_for(std::chrono::milliseconds(2100));
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Save conflict" &&
ui.widgets().find("autosave-status")->tooltip.find("Save As") !=
std::string::npos,
"Disk conflict remains visible with Save As guidance");
renderer.render(ui.snapshot());
renderer.capture(root / "autosave-conflict.ppm");
check(ui.widgets().focus("autosave-save-as"),
"Keyboard focus reaches the recovery Save As action");
ui.frame({key("Return")});
check(ui.widgets().find("menu-popup")->visible &&
ui.widgets().find("save-path")->visible,
"Recovery action opens an editable Save As path");
ui.frame({key("Escape")});
const auto unnamed = session.authoring().create("Unsaved", 2);
session.authoring().transact(
unnamed.at("id"), unnamed.at("revision"),
Json::array({{{"op", "scene.rename"}, {"name", "Needs a path"}}}));
ui.select_document(unnamed.at("id"));
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Save As required" &&
ui.widgets().find("autosave-save-as")->visible,
"Unnamed dirty scene is never assigned an implicit path");
open(ui);
renderer.render(ui.snapshot());
renderer.capture(root / "project-settings.ppm");
+80
View File
@@ -0,0 +1,80 @@
"""Contracts for reproducible, honestly labelled P1 workflow measurements."""
import importlib.util
from pathlib import Path
import unittest
MODULE = Path(__file__).resolve().parents[1] / "tools/measure_workflows.py"
SPEC = importlib.util.spec_from_file_location("measure_workflows", MODULE)
workflows = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(workflows)
def report(samples):
return {
"format": "faset.workflow-measurements",
"version": 2,
"revision": "0123456789abcdef",
"source_hashes": {"Scripts/Gameplay.cpp": "a" * 64},
"build_configuration": "Debug",
"samples": samples,
}
class WorkflowReportTests(unittest.TestCase):
def test_nearest_rank_summary(self):
self.assertEqual(workflows.summarize([1, 2, 3, 4, 5]),
{"median": 3, "p95": 5})
def test_summary_rejects_empty_and_invalid_samples(self):
for values in ([], [0, -1], [1, float("nan")]):
with self.subTest(values=values), self.assertRaises(ValueError):
workflows.summarize(values)
def test_report_requires_revision_hashes_and_repetitions(self):
samples = {"warm_unchanged_build": [
{"seconds": 1.0, "build_configuration": "Debug", "source_hash": "a" * 64}
for _ in range(5)]}
workflows.validate_report(report(samples))
for mutation in (
lambda value: value.update(revision=""),
lambda value: value.update(source_hashes={}),
lambda value: value["samples"]["warm_unchanged_build"].pop(),
lambda value: value["samples"]["warm_unchanged_build"][0].update(
build_configuration="Release"),
):
value = report({"warm_unchanged_build": [dict(sample) for sample in samples["warm_unchanged_build"]]})
mutation(value)
with self.subTest(value=value), self.assertRaises(ValueError):
workflows.validate_report(value)
def test_windowed_claim_rejects_offscreen_profile(self):
value = report({"windowed_first_presented_frame": [{
"seconds": 0.1, "build_configuration": "Debug",
"source_hash": "a" * 64, "presentation_mode": "offscreen"}]})
with self.assertRaises(ValueError):
workflows.validate_report(value)
def test_generated_scene_is_seeded_and_preserves_source(self):
scene = {"format": "faset.scene", "version": 1, "entities": [{
"id": "reference", "name": "Reference", "parent": None,
"components": [{"id": "transform", "type": "faset.transform", "version": 1,
"fields": {"position": [0, 0, 0]}}]}]}
first = workflows.generate_scene_fixture(scene, seed=17, count=8)
second = workflows.generate_scene_fixture(scene, seed=17, count=8)
self.assertEqual(first, second)
self.assertEqual(len(first["entities"]), 9)
self.assertEqual(scene["entities"][0]["id"], "reference")
self.assertEqual(len({entry["id"] for entry in first["entities"]}), 9)
self.assertNotEqual(first, workflows.generate_scene_fixture(scene, seed=18, count=8))
def test_gnu_time_keeps_rss_after_expected_command_failure(self):
self.assertEqual(workflows.parse_peak_rss(
'Command exited with non-zero status 1\n{"peak_rss_kib":353272}\n'), 353272)
with self.assertRaises(ValueError):
workflows.parse_peak_rss("Command exited with non-zero status 1\n")
if __name__ == "__main__":
unittest.main()
+105
View File
@@ -0,0 +1,105 @@
"""Small contract checks for the cross-platform export verifier's Lua mode."""
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from verify_playable_exports import (device_class, project_matrix, project_workspace,
sha256, verify_capture, verify_lua_export,
verify_lua_execution, verify_package,
vulkan_device_details)
class ExportProjectMatrixTests(unittest.TestCase):
def test_native_project_workspace_is_outside_engine(self):
with tempfile.TemporaryDirectory() as directory:
engine = Path(directory) / "engine"
engine.mkdir()
internal_output = engine / ".cache" / "exports"
workspace = project_workspace(engine, internal_output)
try:
self.assertFalse(workspace.is_relative_to(engine))
finally:
workspace.rmdir()
external_output = Path(directory) / "evidence"
self.assertEqual(project_workspace(engine, external_output), external_output)
def test_lua_is_explicit_and_can_run_alone(self):
self.assertEqual([name for name, _, _ in project_matrix(False, False)],
["collect-2d", "collect-3d"])
self.assertEqual([name for name, _, _ in project_matrix(True, False)],
["collect-2d", "collect-3d", "lua"])
self.assertEqual(project_matrix(False, True), [("lua", 2, "lua")])
def test_lua_package_requires_source_and_license_without_cpp_stub(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Scripts").mkdir()
(root / "Notices/lua").mkdir(parents=True)
(root / "Scripts/player.lua").write_text("return {}\n", encoding="utf-8")
(root / "Notices/lua/LICENSE.txt").write_text("License\n", encoding="utf-8")
manifest = {"lua_enabled": True, "files": [
{"path": "Scripts/player.lua"}, {"path": "Notices/lua/LICENSE.txt"}]}
verify_lua_export(root, manifest)
with self.assertRaisesRegex(RuntimeError, "license"):
verify_lua_export(root, {**manifest, "files": manifest["files"][:1]})
(root / "Scripts/GameplayExtra.cpp").write_text("// stray\n", encoding="utf-8")
manifest["files"].append({"path": "Scripts/GameplayExtra.cpp"})
with self.assertRaisesRegex(RuntimeError, "C\\+\\+"):
verify_lua_export(root, manifest)
(root / "Scripts/GameplayExtra.cpp").unlink()
manifest["files"].pop()
(root / "Notices/lua/LICENSE.txt").unlink()
with self.assertRaisesRegex(RuntimeError, "license"):
verify_lua_export(root, manifest)
def test_lua_capture_requires_its_small_scene_palette(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "capture.ppm"
header = b"P6\n4 1\n255\n"
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5ffb233"))
self.assertEqual(verify_capture(path, language="lua")["sampled_colors"], 4)
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
verify_capture(path)
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5334c66"))
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
verify_capture(path, language="lua")
def test_driver_probe_selects_the_rendering_device(self):
summary = ("GPU0:\n deviceName = llvmpipe\n driverName = Mesa\n"
"GPU1:\n deviceName = Discrete GPU\n driverVersion = 123\n"
" driverName = Vendor\n driverInfo = 1.2.3\n")
self.assertEqual(vulkan_device_details(summary, "Discrete GPU"),
{"deviceName": "Discrete GPU", "driverVersion": "123",
"driverName": "Vendor", "driverInfo": "1.2.3"})
self.assertEqual(vulkan_device_details(summary, "Unknown"), {})
self.assertEqual(device_class("SwiftShader Device", {}), "software")
self.assertEqual(device_class("NVIDIA GeForce RTX 2080 Ti", {}), "physical")
self.assertEqual(device_class("Mystery Device", {}), "unknown")
def test_package_rejects_undeclared_file(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Scripts").mkdir()
(root / "Scripts/player.lua").write_text("return {}\n", encoding="utf-8")
file = root / "Scripts/player.lua"
manifest = {"format": "faset.export", "version": 1,
"configuration": "Release", "files": [
{"path": "Scripts/player.lua", "size": file.stat().st_size,
"sha256": sha256(file)}]}
import json
(root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
verify_package(root)
(root / "Scripts/GameplayExtra.cpp").write_text("// stray\n", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "undeclared"):
verify_package(root)
def test_lua_execution_requires_on_start_marker(self):
verify_lua_execution("Lua player ready: A/D move, Space jump, E reset\n")
with self.assertRaisesRegex(RuntimeError, "on_start"):
verify_lua_execution("")
if __name__ == "__main__":
unittest.main()
+423 -32
View File
@@ -5,32 +5,173 @@ Numbers are observations, not CI pass/fail thresholds. Linux peak RSS comes from
GNU time for the command and its waited-for children; other platforms report null.
"""
import argparse
import copy
import datetime
import hashlib
import json
import math
import os
from pathlib import Path
import platform
import queue
import random
import shutil
import statistics
import subprocess
import threading
import time
def summarize(values):
"""Median and nearest-rank p95 of positive, finite observations."""
if not values or any(not isinstance(value, (int, float)) or
not math.isfinite(value) or value <= 0 for value in values):
raise ValueError("A distribution requires positive finite samples")
ordered = sorted(values)
return {"median": statistics.median(ordered),
"p95": ordered[math.ceil(.95 * len(ordered)) - 1]}
def parse_peak_rss(text):
# GNU time prefixes its -o output with a status sentence when the measured
# command exits nonzero. The JSON format is the final nonempty line.
for line in reversed(text.splitlines()):
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(value, dict) and isinstance(value.get("peak_rss_kib"), int):
return value["peak_rss_kib"]
raise ValueError("GNU time did not produce a peak RSS record")
def validate_report(report):
if report.get("format") != "faset.workflow-measurements" or report.get("version") != 2:
raise ValueError("Expected a version 2 Faset workflow report")
if not isinstance(report.get("revision"), str) or not report["revision"].strip():
raise ValueError("A source revision is required")
hashes = report.get("source_hashes")
if not isinstance(hashes, dict) or not hashes or any(
not isinstance(value, str) or len(value) != 64 or
any(character not in "0123456789abcdef" for character in value)
for value in hashes.values()):
raise ValueError("Exact input SHA-256 hashes are required")
configuration = report.get("build_configuration")
if configuration not in {"Debug", "Release", "RelWithDebInfo"}:
raise ValueError("Build configuration is missing or unknown")
samples = report.get("samples")
if not isinstance(samples, dict) or not samples:
raise ValueError("Raw labelled samples are required")
for label, rows in samples.items():
if not isinstance(rows, list) or not rows:
raise ValueError(f"{label} has no raw samples")
if (label.startswith("warm_") or label.startswith("changed_")) and len(rows) < 5:
raise ValueError(f"{label} requires at least five measured repetitions")
for row in rows:
if row.get("build_configuration") != configuration:
raise ValueError(f"{label} mixes build configurations")
if row.get("source_hash") not in hashes.values():
raise ValueError(f"{label} lacks a declared input source hash")
summarize([row.get("seconds")])
if label == "windowed_first_presented_frame" and \
row.get("presentation_mode") != "windowed":
raise ValueError("An offscreen frame cannot be labelled windowed presentation")
if label == "offscreen_first_rendered_frame" and \
row.get("presentation_mode") != "offscreen":
raise ValueError("A windowed frame cannot be labelled offscreen")
def generate_scene_fixture(scene, *, seed, count):
"""Add deterministic visible objects without mutating the checked-in scene."""
if not isinstance(seed, int) or count < 0 or not scene.get("entities"):
raise ValueError("A seed, nonnegative count, and source entity are required")
generated = copy.deepcopy(scene)
rng = random.Random(seed)
reference = next((entry for entry in scene["entities"] if any(
component.get("type") in {"faset.mesh", "faset.sprite"}
for component in entry.get("components", []))), scene["entities"][0])
for index in range(count):
entity = copy.deepcopy(reference)
entity["id"] = f"measured-{seed}-{index:05d}"
entity["name"] = f"Measured object {index + 1}"
entity["parent"] = None
entity["components"] = [component for component in entity.get("components", [])
if component.get("type") in {"faset.transform", "faset.mesh",
"faset.sprite"}]
for component in entity["components"]:
component["id"] = f"{entity['id']}-{component['type']}"
fields = component.get("fields", {})
if component["type"] == "faset.transform":
position = list(fields.get("position", [0, 0, 0]))
position[0] = ((index % 32) - 16) * .45 + rng.uniform(-.05, .05)
position[2 if scene.get("dimension", 3) == 3 else 1] = \
(index // 32) * .45 + rng.uniform(-.05, .05)
fields["position"] = position
fields["scale"] = [.3, .3, .3]
elif component["type"] == "faset.sprite":
fields["size"] = [.3, .3]
generated["entities"].append(entity)
return generated
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--editor", type=Path, required=True)
parser.add_argument("--project", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--lua-project", type=Path,
default=Path(__file__).resolve().parents[1] / "examples/lua")
parser.add_argument("--ui-latency-binary", type=Path)
args = parser.parse_args()
editor, source, output = args.editor.resolve(), args.project.resolve(), args.output.resolve()
lua_source = args.lua_project.resolve()
ui_binary = (args.ui_latency_binary or
editor.parent / ("faset_editor_ui_latency.exe" if os.name == "nt" else
"faset_editor_ui_latency")).resolve()
for file in (editor, ui_binary, source / "project.faset.json",
lua_source / "project.faset.json"):
if not file.is_file():
raise RuntimeError(f"Required measurement input is missing: {file}")
if output.exists():
raise RuntimeError("Use a new output directory; existing evidence is never overwritten")
output.mkdir(parents=True)
project = output / "project"
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
samples = []
lua_project = output / "lua-project"
shutil.copytree(lua_source, lua_project,
ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
raw = output / "raw"
raw.mkdir()
samples = {}
source_hashes = {}
counters = {}
def run(label, arguments, timeout=1800):
memory = output / (label + "-memory.json")
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def remember(path, variant, base=project):
key = f"{base.name}/{path.relative_to(base).as_posix()}#{variant}"
value = digest(path)
source_hashes[key] = value
return value
gameplay = project / "Scripts/Gameplay.cpp"
header = project / "Scripts/Gameplay.hpp"
if not gameplay.is_file() or not header.is_file():
raise RuntimeError("The C++ project needs Scripts/Gameplay.cpp and Gameplay.hpp")
baseline_hash = remember(gameplay, "baseline")
project_hashes = {file.relative_to(project).as_posix(): digest(file)
for file in sorted(project.rglob("*")) if file.is_file()}
lua_input = json.loads((lua_project / "project.faset.json").read_text(encoding="utf-8"))
lua_file = lua_project / lua_input["scripting"]["lua"]["scripts"][0]
lua_baseline_hash = remember(lua_file, "baseline", lua_project)
def run(label, arguments, *, source_hash=baseline_hash, timeout=1800,
expected_failure=False, record=True):
index = counters.get(label, 0) + 1
counters[label] = index
stem = f"{label}-{index:02d}"
memory = raw / (stem + "-memory.json")
command = [str(arg) for arg in arguments]
wrapped = command
if platform.system() == "Linux" and Path("/usr/bin/time").is_file():
@@ -39,44 +180,294 @@ def main():
result = subprocess.run(wrapped, cwd=output, capture_output=True, text=True,
encoding="utf-8", timeout=timeout)
elapsed = time.perf_counter() - started
(output / (label + "-stdout.txt")).write_text(result.stdout, encoding="utf-8")
(output / (label + "-stderr.txt")).write_text(result.stderr, encoding="utf-8")
sample = {"name": label, "seconds": elapsed, "returncode": result.returncode,
"peak_rss_kib": json.loads(memory.read_text())["peak_rss_kib"] if memory.exists() else None}
samples.append(sample)
(output / "progress.json").write_text(json.dumps(samples, indent=2), encoding="utf-8")
if result.returncode:
stdout_file, stderr_file = raw / (stem + "-stdout.txt"), raw / (stem + "-stderr.txt")
stdout_file.write_text(result.stdout, encoding="utf-8")
stderr_file.write_text(result.stderr, encoding="utf-8")
sample = {"seconds": elapsed, "returncode": result.returncode,
"build_configuration": "Debug", "source_hash": source_hash,
"command": command, "stdout": str(stdout_file.relative_to(output)),
"stderr": str(stderr_file.relative_to(output)),
"peak_rss_kib": parse_peak_rss(memory.read_text())
if memory.exists() else None}
(raw / (stem + ".json")).write_text(json.dumps(sample, indent=2) + "\n",
encoding="utf-8")
if record:
samples.setdefault(label, []).append(sample)
(output / "progress.json").write_text(json.dumps(samples, indent=2) + "\n",
encoding="utf-8")
if expected_failure and result.returncode == 0:
raise RuntimeError(f"{label} unexpectedly succeeded")
if result.returncode and not expected_failure:
raise RuntimeError(f"{label} failed: {result.stderr[-4000:]} {result.stdout[-2000:]}")
print(f"{label}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True)
return result
print(f"{stem}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True)
return result, sample
def call(label, name, arguments=None):
result = run(label, [editor, "--project", project, "--command",
json.dumps({"name": name, "arguments": arguments or {}}), "--wait"])
def call(label, name, arguments=None, *, root=project, source_hash=baseline_hash,
record=True):
result, sample = run(label, [editor, "--project", root, "--command",
json.dumps({"name": name, "arguments": arguments or {}}),
"--wait"], source_hash=source_hash, record=record)
document = json.loads(result.stdout)
if "state" in document and document["state"] != "succeeded":
raise RuntimeError(f"{label} job did not succeed: {document}")
return document
if name == "faset_build":
sample["schema_cache_hit"] = document.get("result", {}).get("schema_cache_hit")
sample["generation_reused"] = document.get("result", {}).get("generation_reused")
return document, sample
for index in range(3):
call(f"headless-startup-{index + 1}", "faset_project")
# Separate GUI startup from headless command latency. Includes two frames and
# shutdown, not a claimed first-visible-frame timestamp.
run("editor-gui-two-frames", [editor, "--project", project, "--frames", "2"])
call("headless_editor_command_startup", "faset_project")
# This is window creation plus two frames and shutdown, not first-present latency.
run("windowed_editor_two_frames", [editor, "--project", project, "--frames", "2"])
manifest = project / "Assets/exit-arch/manifest.json"
if manifest.exists():
call("import-empty-cache", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("import-existing-generation", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("initial-debug-build", "faset_build")
call("unchanged-debug-build", "faset_build")
gameplay = project / "Scripts/Gameplay.cpp"
original = gameplay.read_bytes()
gameplay.write_bytes(original + b"\n// Measurement: one gameplay translation unit changed.\n")
# Verify the user-facing stale status before a successful replacement.
assert call("schema-stale-after-edit", "faset_schema_status")["stale"]
iteration_started = time.perf_counter()
built = call("changed-debug-build", "faset_build")["result"]
profile = output / "development-player.json"
call("cold_asset_import", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("cached_asset_import", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
cold, _ = call("cold_configure_build", "faset_build")
call("warmup_unchanged_build", "faset_build", record=False)
for _ in range(5):
reused, _ = call("warm_unchanged_build", "faset_build")
if not reused["result"].get("schema_cache_hit"):
raise RuntimeError("An unchanged build did not hit the verified schema cache")
original_cpp = gameplay.read_bytes()
original_header = header.read_bytes()
for index in range(6):
gameplay.write_bytes(original_cpp +
f"\n// P1 C++ iteration {index}\n".encode("utf-8"))
source_hash = remember(gameplay, f"cpp-edit-{index}")
stale, _ = call("schema_stale_check", "faset_schema_status",
source_hash=source_hash, record=False)
if not stale["stale"]:
raise RuntimeError("A C++ source edit did not mark gameplay metadata stale")
label = "warmup_changed_cpp_build" if index == 0 else "changed_cpp_build"
call(label, "faset_build", source_hash=source_hash, record=index != 0)
for index in range(6):
header.write_bytes(original_header +
f"\n// P1 header iteration {index}\n".encode("utf-8"))
source_hash = remember(header, f"header-edit-{index}")
label = "warmup_changed_header_build" if index == 0 else "changed_header_build"
call(label, "faset_build", source_hash=source_hash, record=index != 0)
working_header = header.read_bytes()
header.write_bytes(working_header + b"\n#error FASET_P1_EXPECTED_BUILD_FAILURE\n")
failed_hash = remember(header, "intentional-failure")
run("build_failure", [editor, "--project", project, "--command",
json.dumps({"name": "faset_build", "arguments": {}}), "--wait"],
source_hash=failed_hash, expected_failure=True)
header.write_bytes(working_header)
recovery_hash = remember(header, "recovery")
built, _ = call("build_recovery", "faset_build", source_hash=recovery_hash)
settings = json.loads((project / "project.faset.json").read_text(encoding="utf-8"))
scene = project / settings["start_scene"]
player = Path(built["result"]["player"])
player_profiles = []
for index in range(5):
profile = raw / f"cpp-first-frame-{index + 1:02d}.json"
run("player_one_frame_process", [player, "--scene", scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"1", "--profile", profile],
source_hash=recovery_hash)
profile_data = json.loads(profile.read_text(encoding="utf-8"))
if profile_data["presentation_mode"] != "offscreen" or \
profile_data["completed_frames"] != 1:
raise RuntimeError("The first-frame profile is not one rendered offscreen frame")
player_profiles.append(profile_data)
samples.setdefault("offscreen_first_rendered_frame", []).append({
"seconds": profile_data["startup_ms"]["main_to_first_frame"] / 1000,
"build_configuration": "Debug", "source_hash": recovery_hash,
"presentation_mode": profile_data["presentation_mode"],
"profile": str(profile.relative_to(output))})
# The UI probe measures synthetic keyboard input through snapshot rendering,
# with no display presentation claim.
ui_profile = raw / "editor-input-latency.json"
run("editor_ui_probe_process", [ui_binary, ui_profile], source_hash=recovery_hash)
ui_data = json.loads(ui_profile.read_text(encoding="utf-8"))
if ui_data["presentation_mode"] != "offscreen" or \
ui_data["measured_frames"] != len(ui_data["samples"]):
raise RuntimeError("UI probe lost raw frames or mislabelled presentation")
samples["editor_input_visible_frame"] = [
{"seconds": row["milliseconds"] / 1000, "build_configuration": "Debug",
"source_hash": recovery_hash, "presentation_mode": "offscreen",
"input": row["gizmo"], "profile": str(ui_profile.relative_to(output))}
for row in ui_data["samples"]]
original_lua = lua_file.read_bytes()
lua_build, _ = call("lua_initial_build", "faset_build", root=lua_project,
source_hash=lua_baseline_hash)
lua_player = Path(lua_build["result"]["player"])
lua_scene = lua_project / lua_input["start_scene"]
control = lua_project / ".faset/measurement-control.json"
process = subprocess.Popen([str(lua_player), "--scene", str(lua_scene), "--project",
str(lua_project), "--control", str(control), "--watch-lua",
"--headless", "--frames", "10000000"], cwd=output,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8")
events = queue.Queue()
stderr_lines = []
def collect_lua():
for line in process.stderr:
event = (time.perf_counter(), line)
stderr_lines.append(event)
events.put(event)
reader = threading.Thread(target=collect_lua, daemon=True)
reader.start()
def wait_lua(marker, timeout=20):
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
try:
event = events.get(timeout=min(.2, max(.001, deadline - time.perf_counter())))
except queue.Empty:
if process.poll() is not None:
break
continue
if marker in event[1]:
return event[0]
raise RuntimeError(f"Lua Player did not report {marker}; see raw/lua-reload-stderr.txt")
try:
# Startup settles before the watch/reload timing begins.
wait_lua("Lua", timeout=20)
for index in range(6):
lua_file.write_bytes(original_lua +
f"\n-- P1 reload iteration {index}\n".encode("utf-8"))
started = time.perf_counter()
source_hash = remember(lua_file, f"reload-{index}", lua_project)
finished = wait_lua("Lua reloaded:")
if index:
samples.setdefault("changed_lua_reload", []).append({
"seconds": finished - started, "build_configuration": "Debug",
"source_hash": source_hash, "presentation_mode": "offscreen",
"player_log": "raw/lua-reload-stderr.txt"})
control.parent.mkdir(parents=True, exist_ok=True)
staging = control.with_suffix(".tmp")
staging.write_text(json.dumps({"sequence": 1, "command": "stop"}),
encoding="utf-8")
staging.replace(control)
process.wait(timeout=20)
if process.returncode:
raise RuntimeError("Lua Player failed during reload measurements")
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)
(raw / "lua-reload-stdout.txt").write_text(process.stdout.read(), encoding="utf-8")
(raw / "lua-reload-stderr.txt").write_text("".join(line for _, line in stderr_lines),
encoding="utf-8")
lua_file.write_bytes(original_lua)
lifecycle_profile = raw / "cpp-3000-frame-lifecycle.json"
run("resource_lifecycle_process", [player, "--scene", scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"3000", "--profile", lifecycle_profile],
source_hash=recovery_hash)
lifecycle = json.loads(lifecycle_profile.read_text(encoding="utf-8"))
frames = lifecycle["samples"]
if len(frames) != 3000:
raise RuntimeError("Resource lifecycle did not complete 3,000 frames")
memory_start = max(row["gpu_allocated_bytes"] for row in frames[100:200])
memory_end = max(row["gpu_allocated_bytes"] for row in frames[-100:])
resource_growth = {"reference_frames": [101, 200], "end_frames": [2901, 3000],
"explicit_gpu_bytes_reference_max": memory_start,
"explicit_gpu_bytes_end_max": memory_end,
"explicit_gpu_bytes_growth": memory_end - memory_start,
"texture_count_reference_max": max(row["texture_count"]
for row in frames[100:200]),
"texture_count_end_max": max(row["texture_count"]
for row in frames[-100:])}
generated_scene = generate_scene_fixture(json.loads(scene.read_text(encoding="utf-8")),
seed=20260924, count=512)
large_scene = raw / "generated-512.scene.json"
large_scene.write_text(json.dumps(generated_scene, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
large_hash = remember(large_scene, "generated-512", output)
large_profile = raw / "generated-512-profile.json"
run("larger_generated_scene_process", [player, "--scene", large_scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"240", "--profile", large_profile],
source_hash=large_hash)
large_data = json.loads(large_profile.read_text(encoding="utf-8"))
engine = Path(__file__).resolve().parents[1]
revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=engine,
capture_output=True, text=True, check=True).stdout.strip()
dirty = subprocess.run(["git", "status", "--porcelain"], cwd=engine,
capture_output=True, text=True, check=True).stdout.strip()
cpu = platform.processor()
if Path("/proc/cpuinfo").exists():
cpu = next((line.split(":", 1)[1].strip() for line in
Path("/proc/cpuinfo").read_text().splitlines()
if line.startswith("model name")), cpu)
ram_kib = None
if Path("/proc/meminfo").exists():
ram_kib = next((int(line.split()[1]) for line in
Path("/proc/meminfo").read_text().splitlines()
if line.startswith("MemTotal:")), None)
def version(arguments):
try:
return subprocess.run(arguments, capture_output=True, text=True, timeout=15,
encoding="utf-8").stdout.splitlines()[0]
except (OSError, subprocess.TimeoutExpired, IndexError):
return None
report = {
"format": "faset.workflow-measurements", "version": 2,
"recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"revision": revision, "working_tree_dirty": bool(dirty),
"source_hashes": source_hashes, "project_hashes_at_copy": project_hashes,
"build_configuration": "Debug", "source_project": str(source),
"lua_source_project": str(lua_source), "editor": str(editor),
"host": {"platform": platform.platform(), "cpu": cpu, "ram_kib": ram_kib,
"logical_cpus": os.cpu_count(), "python": platform.python_version()},
"toolchain": {"cmake": version(["cmake", "--version"]),
"cxx": version(["clang++", "--version"]),
"slang": version(["slangc", "-version"])},
"gpu": {"player_device": player_profiles[0]["device"],
"ui_device": ui_data["device"],
"driver": version(["nvidia-smi", "--query-gpu=driver_version",
"--format=csv,noheader"])},
"settings": {"resolution": [player_profiles[0]["width"],
player_profiles[0]["height"]],
"validation_enabled": player_profiles[0]["validation_enabled"],
"readback_enabled": True, "presentation_mode": "offscreen",
"source_scene_entities": len(json.loads(scene.read_text())["entities"]),
"generated_scene_entities": len(generated_scene["entities"])},
"samples": samples,
"summary_seconds": {label: summarize([row["seconds"] for row in rows])
for label, rows in samples.items()},
"resource_lifecycle": resource_growth,
"large_scene_summary_ms": large_data["summary_ms"],
"coverage": {"windowed_first_presented_frame":
"unmeasured; only offscreen rendered frames were sampled",
"reference_release_budgets":
"not compared with this Debug development workflow"},
"method": [
"Fresh disposable C++ and Lua project copies; checked-in sources never modified.",
"Cold build means empty project .faset caches, not cold OS cache or dependency download.",
"Warm/changed C++ and header builds have one warm-up then five measured repetitions.",
"Lua edit-to-reload uses a persistent watched Player, one warm-up and five edits; "
"wall time includes up to 500 ms watcher polling and stderr notification.",
"First frame is Player main-to-first-completed offscreen frame; OS loader excluded.",
"UI latency uses 10 warm-up and 100 keyboard-input to offscreen-render frames; "
"there is no window presentation timing claim.",
"The 3,000-frame lifecycle compares maximum explicit Vulkan allocation and texture "
"counts in frames 101-200 and 2901-3000; it is not a whole-process leak proof.",
"Times are observations under ambient host load, not CI pass/fail thresholds."]}
validate_report(report)
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Report: {output / 'report.json'}", flush=True)
settings = json.loads((project / "project.faset.json").read_text(encoding="utf-8"))
run("changed-build-player-first-frame", [built["player"], "--scene",
project / settings["start_scene"], "--assets", project / ".faset/cache",
+22
View File
@@ -0,0 +1,22 @@
-- Faset starter gameplay. Edit this file and use Refresh Lua or development reload;
-- the native C++ engine does not need to recompile for a Lua-only edit.
local Player = faset.behavior {
id = "starter.player",
version = 1,
name = "Player",
fields = {
speed = { name = "Move speed", type = "number", default = 4, min = 0 },
jump_speed = { name = "Jump speed", type = "number", default = 5, min = 0 }
}
}
function Player:fixed_update(delta)
local velocity = self.entity:velocity()
velocity.x = faset.input().horizontal * self.fields.speed
if faset.input().jump_pressed and self.entity:is_grounded() then
velocity.y = self.fields.jump_speed
end
self.entity:set_velocity(velocity)
end
return Player
+136 -13
View File
@@ -15,6 +15,7 @@ import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
@@ -41,6 +42,38 @@ def sha256(path: Path) -> str:
return hashlib.file_digest(stream, "sha256").hexdigest()
def vulkan_device_details(summary: str, device: str) -> dict:
"""Return the driver identity from vulkaninfo for the Player-selected device."""
for section in re.split(r"(?m)^GPU\d+:\s*$", summary)[1:]:
fields = dict(re.findall(
r"(?m)^\s*(apiVersion|driverVersion|driverID|driverName|driverInfo|deviceName)"
r"\s*=\s*(.+?)\s*$", section))
if fields.get("deviceName") == device:
return fields
return {}
def device_class(device: str, driver: dict) -> str:
identity = " ".join((device, driver.get("driverName", ""), driver.get("driverInfo", ""))).lower()
if any(name in identity for name in ("swiftshader", "llvmpipe", "lavapipe", "softpipe")):
return "software"
if any(name in identity for name in ("nvidia", "geforce", "radeon", "amd", "intel", "arc")):
return "physical"
return "unknown"
def git_identity(engine: Path) -> dict:
def git(*arguments: str) -> str:
return subprocess.run(["git", "-C", str(engine), *arguments], check=True,
capture_output=True, text=True).stdout
revision = git("rev-parse", "HEAD").strip()
status = git("status", "--porcelain", "--untracked-files=normal")
diff = git("diff", "--binary", "HEAD")
return {"revision": revision, "dirty": bool(status),
"status_lines": status.splitlines(),
"diff_sha256": hashlib.sha256(diff.encode("utf-8")).hexdigest() if diff else None}
def run(arguments: list[str | Path], cwd: Path, log: Path, timeout: int = 1800) -> dict:
command = [str(argument) for argument in arguments]
print(f"{log.stem}: {subprocess.list2cmdline(command)}", flush=True)
@@ -93,10 +126,15 @@ def verify_package(directory: Path) -> dict:
require(path.is_file() and not path.is_symlink(), f"Missing packaged file: {path}")
require(path.stat().st_size == entry["size"] and sha256(path) == entry["sha256"],
f"Packaged file checksum mismatch: {path}")
declared = [entry["path"] for entry in manifest["files"]]
actual = {path.relative_to(directory).as_posix() for path in directory.rglob("*")
if path.is_file() or path.is_symlink()}
require(len(declared) == len(set(declared)) and actual == set(declared) | {"manifest.json"},
"Package has undeclared, duplicate or missing files")
return manifest
def verify_capture(path: Path) -> dict:
def verify_capture(path: Path, *, language: str = "cpp") -> dict:
header, dimensions, maximum, pixels = path.read_bytes().split(b"\n", 3)
require(header == b"P6" and maximum == b"255", "Expected RGB PPM screenshot")
width, height = (int(value) for value in dimensions.split())
@@ -104,10 +142,57 @@ def verify_capture(path: Path) -> dict:
"Incomplete screenshot")
step = 3 * max(1, width * height // 10000)
colors = len({pixels[index:index + 3] for index in range(0, len(pixels), step)})
require(colors >= 6, "Screenshot lacks the expected game geometry/colors")
# The checked-in Lua sample deliberately has four flat palette colors.
require(colors >= (4 if language == "lua" else 6),
"Screenshot lacks the expected game geometry/colors")
if language == "lua":
sampled = {pixels[index:index + 3] for index in range(0, len(pixels), step)}
require(any(r > 220 and g > 120 and b < 100 for r, g, b in sampled) and
any(r < 100 and g > 150 and b > 180 for r, g, b in sampled),
"Lua screenshot lacks its gold collectible or cyan player")
return {"width": width, "height": height, "sampled_colors": colors, "sha256": sha256(path)}
def project_matrix(include_lua: bool, only_lua: bool) -> list[tuple[str, int, str]]:
"""Keep the existing two-game default; explicitly opt in to the Lua export."""
if only_lua:
return [("lua", 2, "lua")]
projects = [("collect-2d", 2, "cpp"), ("collect-3d", 3, "cpp")]
if include_lua:
projects.append(("lua", 2, "lua"))
return projects
def project_workspace(engine: Path, output: Path) -> Path:
"""Native builds must never be staged under the engine source tree."""
if output.is_relative_to(engine):
return Path(tempfile.mkdtemp(prefix="faset-playable-projects-"))
return output
def verify_lua_export(directory: Path, manifest: dict) -> None:
require(manifest.get("lua_enabled") is True, "Lua export did not enable the Lua runtime")
scripts = [entry["path"] for entry in manifest["files"]
if entry["path"].startswith("Scripts/") and entry["path"].endswith(".lua")]
require(scripts and all((directory / path).is_file() for path in scripts),
"Lua export has no packaged gameplay source")
require("Notices/lua/LICENSE.txt" in {entry["path"] for entry in manifest["files"]} and
(directory / "Notices/lua/LICENSE.txt").is_file(),
"Lua export is missing its license notice")
require(not any(path.startswith("Scripts/") and
path.lower().endswith((".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h"))
for path in (entry["path"] for entry in manifest["files"])),
"Lua-only export unexpectedly contains C++ gameplay source")
require(not any(path.endswith(".luarc.json") for path in
(entry["path"] for entry in manifest["files"])),
"Lua-only export includes Editor language-server configuration")
def verify_lua_execution(stderr: str) -> None:
require("Lua player ready: A/D move, Space jump, E reset" in stderr,
"Lua gameplay on_start did not execute in the relocated Player")
def main() -> int:
# Keep captured CI logs portable even when the Windows console uses a legacy code page.
sys.stdout.reconfigure(encoding="utf-8")
@@ -117,6 +202,10 @@ def main() -> int:
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--standalone-root", type=Path,
help="New/empty directory outside the engine and output trees")
parser.add_argument("--include-lua", action="store_true",
help="Also export, relocate, validate and render examples/lua")
parser.add_argument("--only-lua", action="store_true",
help="Run only the Lua project (useful for focused local verification)")
args = parser.parse_args()
engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve()
require(editor.is_file(), f"Editor does not exist: {editor}")
@@ -129,26 +218,50 @@ def main() -> int:
require(not standalone.exists() or not any(standalone.iterdir()),
"Standalone root must be new/empty")
standalone.mkdir(parents=True, exist_ok=True)
evidence, projects = output / "evidence", output / "Faset Café 世界"
workspace = project_workspace(engine, output)
evidence, projects = output / "evidence", workspace / "Faset Café 世界"
evidence.mkdir()
projects.mkdir()
source_identity = git_identity(engine)
vulkan_summary = ""
if shutil.which("vulkaninfo"):
try:
probe = subprocess.run(["vulkaninfo", "--summary"], capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=30)
if probe.returncode == 0:
vulkan_summary = probe.stdout
(evidence / "vulkaninfo-summary.txt").write_text(
probe.stdout + probe.stderr, encoding="utf-8")
except (OSError, subprocess.TimeoutExpired):
pass
standalone_projects = standalone / "Faset Café 世界"
standalone_projects.mkdir()
matrix = project_matrix(args.include_lua, args.only_lua)
report = {"format": "faset.playable-export-verification", "version": 1,
"started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform,
"engine": str(engine), "editor": str(editor), "standalone_root": str(standalone),
"frames_per_game": 120, "status": "running", "projects": []}
disabled = output / "projects-offline"
"engine_revision": source_identity["revision"],
"engine_dirty": source_identity["dirty"],
"engine_git_status": source_identity["status_lines"],
"engine_diff_sha256": source_identity["diff_sha256"],
"editor_sha256": sha256(editor),
"project_workspace": str(workspace),
"vulkaninfo_available": bool(vulkan_summary),
"driver_override": os.environ.get("VK_DRIVER_FILES") or os.environ.get("VK_ICD_FILENAMES"),
"frames_per_game": 120, "status": "running", "projects": [],
"requested_projects": [name for name, _, _ in matrix]}
disabled = workspace / "projects-offline"
try:
for dimension in (2, 3):
name = f"collect-{dimension}d"
source, project = engine / "examples/projects" / name, projects / name
for name, dimension, language in matrix:
source = engine / "examples" / ("lua" if language == "lua" else f"projects/{name}")
project = projects / name
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
inputs = [{"path": path.relative_to(project).as_posix(), "sha256": sha256(path)}
for path in sorted(project.rglob("*")) if path.is_file()]
settings = read_json(project / "project.faset.json")
scene = read_json(relative_path(project, settings["start_scene"]))
item = {"name": name, "dimension": dimension, "source_inputs": inputs}
item = {"name": name, "dimension": dimension, "language": language,
"source_inputs": inputs}
report["projects"].append(item)
if dimension == 3:
imported = command(editor, engine, project, "faset_import",
@@ -164,18 +277,22 @@ def main() -> int:
require(generation == Path(exported["result"]["directory"]).resolve(),
"Export response and published pointer disagree")
manifest = verify_package(generation)
if language == "lua":
verify_lua_export(generation, manifest)
require(dimension != 3 or bool(manifest["asset_generations"]),
"3D game did not package its imported Blender asset")
relocated = standalone_projects / name
shutil.copytree(generation, relocated)
verify_package(relocated)
if language == "lua":
verify_lua_export(relocated, manifest)
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
item.update({"generation": pointer["generation"], "configuration": "Release",
"standalone_directory": str(relocated), "executable": manifest["executable"],
"package_file_count": len(manifest["files"]),
"asset_generations": manifest["asset_generations"]})
write_json(output / "report.json", report)
# Hide exactly our disposable source-project paths while launching both games.
# Hide exactly our disposable source-project paths while launching the games.
# An empty, unrelated cwd also catches assumptions about the current directory.
projects.rename(disabled)
working = standalone / "empty-working-directory"
@@ -191,6 +308,8 @@ def main() -> int:
capture, profile = relocated / "verification.ppm", relocated / "profile.json"
rendered = run([player, "--headless", "--frames", "120", "--capture", capture,
"--profile", profile], working, evidence / f"{name}-run.json", 300)
if item["language"] == "lua":
verify_lua_execution(rendered["stderr"])
summary = json.loads(rendered["stdout"].strip().splitlines()[-1])
require(summary["frames"] == 120 and summary["dimension"] == dimension and
summary["validation_errors"] == 0, "Standalone GPU run reported an error")
@@ -201,9 +320,12 @@ def main() -> int:
len(measured["samples"]) == 120, "Invalid or incomplete frame profile")
require(all(frame["gpu_allocated_bytes"] > 0 for frame in measured["samples"]),
"Frame profile lacks Vulkan allocation measurements")
item.update({"device": measured["device"], "validation_enabled": measured["validation_enabled"],
driver = vulkan_device_details(vulkan_summary, measured["device"])
item.update({"device": measured["device"], "device_class": device_class(measured["device"], driver),
"driver": driver, "validation_enabled": measured["validation_enabled"],
"validation_errors": 0, "completed_frames": 120,
"summary_ms": measured["summary_ms"], "capture": verify_capture(capture),
"summary_ms": measured["summary_ms"],
"capture": verify_capture(capture, language=item["language"]),
"source_project_paths_unavailable": True, "status": "passed"})
shutil.copy2(capture, evidence / f"{name}.ppm")
shutil.copy2(profile, evidence / f"{name}-profile.json")
@@ -217,7 +339,8 @@ def main() -> int:
disabled.rename(projects)
report["finished_utc"] = datetime.now(timezone.utc).isoformat()
write_json(output / "report.json", report)
print(f"Both relocated Release games passed: {output / 'report.json'}", flush=True)
print(f"{len(matrix)} relocated Release game(s) passed: {output / 'report.json'}",
flush=True)
return 0