Author SHA1 Message Date
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
26 changed files with 1824 additions and 121 deletions
+50 -7
View File
@@ -4,6 +4,7 @@
#include <faset/editor/mcp.hpp> #include <faset/editor/mcp.hpp>
#include <faset/editor/session.hpp> #include <faset/editor/session.hpp>
#include <iostream> #include <iostream>
#include <memory>
#include <thread> #include <thread>
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
#include <faset/editor/editor_ui.hpp> #include <faset/editor/editor_ui.hpp>
@@ -40,7 +41,7 @@ void help() {
std::cout std::cout
<< "Faset Editor\n" << "Faset Editor\n"
" faset_editor Open the project launcher\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 --mcp [--gui]\n"
" faset_editor --project PATH --command JSON [--wait]\n" " faset_editor --project PATH --command JSON [--wait]\n"
"Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\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; using namespace faset::editor;
try { try {
std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture; 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; int dimension = 3;
bool mcp = false, gui = true, explicit_gui = false, wait = false; bool mcp = false, gui = true, explicit_gui = false, wait = false;
bool explicit_language = false;
std::uint64_t frames = 0; std::uint64_t frames = 0;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i]; const std::string arg = argv[i];
@@ -74,6 +76,10 @@ int editor_main(int argc, char** argv) {
new_name = value(); new_name = value();
else if (arg == "--dimension") else if (arg == "--dimension")
dimension = std::stoi(value()); dimension = std::stoi(value());
else if (arg == "--language") {
language = value();
explicit_language = true;
}
else if (arg == "--scene") else if (arg == "--scene")
scene = path_from_utf8(value()); scene = path_from_utf8(value());
else if (arg == "--mcp") else if (arg == "--mcp")
@@ -100,30 +106,65 @@ int editor_main(int argc, char** argv) {
throw Error("cli.option", "Unknown option: " + arg); throw Error("cli.option", "Unknown option: " + arg);
} }
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command"); 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) if (mcp && !explicit_gui)
gui = false; gui = false;
require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project", require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project",
"Use --project PATH for command, MCP, or --new modes"); "Use --project PATH for command, MCP, or --new modes");
auto previous_project = project; auto previous_project = project;
#ifdef FASET_HAS_EDITOR_UI
std::optional<ProjectSelection> retry_create;
std::string creation_error;
#endif
for (;;) { for (;;) {
if (project.empty()) { if (project.empty()) {
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
const auto selection = 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) if (!selection)
return 0; return 0;
project = selection->path; project = selection->path;
new_name = selection->create ? selection->name : std::string(); new_name = selection->create ? selection->name : std::string();
dimension = selection->dimension; dimension = selection->dimension;
language = selection->language;
explicit_language = selection->create;
#else #else
throw Error("editor.gui_unavailable", throw Error("editor.gui_unavailable",
"This build has no graphical project launcher; use --project PATH"); "This build has no graphical project launcher; use --project PATH");
#endif #endif
} }
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine), std::unique_ptr<Session> session_holder;
executable_directory(argv[0])}); try {
if (!new_name.empty()) session_holder = std::make_unique<Session>(SessionConfig{
session.scaffold(new_name, dimension); 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(); const auto settings = session.project();
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
if (gui && std::filesystem::exists(project / "project.faset.json")) if (gui && std::filesystem::exists(project / "project.faset.json"))
@@ -167,6 +208,8 @@ int editor_main(int argc, char** argv) {
project.clear(); project.clear();
scene.clear(); scene.clear();
new_name.clear(); new_name.clear();
explicit_language = false;
language = "cpp";
continue; continue;
#else #else
throw Error( 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."); throw std::runtime_error("Project name or scene type is invalid.");
return {path, std::move(name), dimension, false}; 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) { Json recent_records(const fs::path& file) {
try { try {
auto j = read_json(file); auto j = read_json(file);
@@ -124,9 +140,11 @@ struct ProjectLauncher::Impl {
std::optional<ProjectSelection> selected; std::optional<ProjectSelection> selected;
bool create = false, cancelled = false, browsing = false, browser_valid = false; bool create = false, cancelled = false, browsing = false, browser_valid = false;
int dimension = 3; int dimension = 3;
std::string language = "cpp";
std::string error; std::string error;
Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial, 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"), : renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
recents_file(recents.empty() ? recent_path() : recents) { recents_file(recents.empty() ? recent_path() : recents) {
auto theme = ui::Theme::load(engine / "assets/ui/dark.json"); auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
@@ -147,13 +165,22 @@ struct ProjectLauncher::Impl {
ui.set_ime( ui.set_ime(
[this](bool enabled) { renderer.set_text_input(enabled); }, [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); }); [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)); 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-open")->on_click = [this](Widget&) { set_mode(false); };
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); }; ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; }; ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; }; 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-submit")->on_click = [this](Widget&) { submit(); };
ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; }; ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; };
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); }; 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."); throw std::runtime_error("Enter a project name.");
if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos) 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."); 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."); throw std::runtime_error("Create requires a new or empty directory.");
auto parent = path.parent_path(); auto parent = path.parent_path();
while (!parent.empty() && !fs::exists(parent)) while (!parent.empty() && !fs::exists(parent))
parent = parent.parent_path(); parent = parent.parent_path();
if (parent.empty() || !fs::is_directory(parent)) if (parent.empty() || !fs::is_directory(parent))
throw std::runtime_error("Project parent directory is unavailable."); throw std::runtime_error("Project parent directory is unavailable.");
selected = ProjectSelection{path, name, dimension, true}; selected = ProjectSelection{path, name, dimension, true, language};
} else } else
selected = open_project(path); selected = open_project(path);
error.clear(); error.clear();
@@ -351,14 +378,24 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->selected = create; ui.find("launcher-create")->selected = create;
ui.find("launcher-2d")->selected = dimension == 2; ui.find("launcher-2d")->selected = dimension == 2;
ui.find("launcher-3d")->selected = dimension == 3; 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 = ui.find("launcher-2d")->appearance =
dimension == 2 ? Appearance::Quiet : Appearance::Default; dimension == 2 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-3d")->appearance = ui.find("launcher-3d")->appearance =
dimension == 3 ? Appearance::Quiet : Appearance::Default; 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", for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label",
"launcher-dimensions", "launcher-name-group", "launcher-dimensions", "launcher-name-group",
"launcher-name-space", "launcher-path-space", "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(id)->visible = create;
ui.find("launcher-heading")->text = create ? "Create project" : "Open project"; ui.find("launcher-heading")->text = create ? "Create project" : "Open project";
ui.find("launcher-submit")->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, ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine,
const fs::path& initial, const fs::path& recents) const fs::path& initial, const fs::path& recents,
: impl_(std::make_unique<Impl>(r, engine, initial, 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; ProjectLauncher::~ProjectLauncher() = default;
void ProjectLauncher::frame(const std::vector<render::Event>& events) { void ProjectLauncher::frame(const std::vector<render::Event>& events) {
impl_->frame(events); impl_->frame(events);
@@ -445,9 +485,11 @@ bool ProjectLauncher::cancelled() const {
std::optional<ProjectSelection> run_project_launcher(const fs::path& engine, std::optional<ProjectSelection> run_project_launcher(const fs::path& engine,
const fs::path& initial, const fs::path& initial,
std::uint64_t max_frames, 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}); 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; std::uint64_t frames = 0;
while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() && while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() &&
(max_frames == 0 || frames < max_frames)) { (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-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-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-space", "kind": "row", "layout": {"height": 22}},
{"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}}, {"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}},
+11 -1
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_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_compile_features(faset_build_service PUBLIC cxx_std_20) target_compile_features(faset_build_service PUBLIC cxx_std_20)
target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads) target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(faset_build_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) add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp)
target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets) target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets)
target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
+13 -3
View File
@@ -31,17 +31,27 @@ cmake --build --preset linux-debug --parallel
ctest --preset linux-debug 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 ```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 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**. Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
The Player runs separately. Stop it before changing and rebuilding C++ gameplay. The Player runs separately. Stop it before changing and rebuilding C++ gameplay.
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. See [MCP and CLI](../editor/mcp.md) for headless authoring and automation.
For an optimized build use `linux-release`. The `linux-sanitize` preset enables For an optimized build use `linux-release`. The `linux-sanitize` preset enables
+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. 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. 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 ## Enable Lua in a project
Add explicit entry scripts to `project.faset.json`: Add explicit entry scripts to `project.faset.json`:
@@ -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

+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 <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
#include <string_view>
#include <vector> #include <vector>
namespace faset::editor { namespace faset::editor {
@@ -22,6 +23,7 @@ struct JobStatus {
std::string id, kind, state{"queued"}, stage{"queued"}; std::string id, kind, state{"queued"}, stage{"queued"};
double progress{}; double progress{};
std::string log, error; std::string log, error;
Json diagnostics = Json::array();
Json result = Json::object(); Json result = Json::object();
bool finished() const { bool finished() const {
return state == "succeeded" || state == "failed" || state == "cancelled"; return state == "succeeded" || state == "failed" || state == "cancelled";
@@ -37,6 +39,9 @@ class BuildService {
BuildService(const BuildService&) = delete; BuildService(const BuildService&) = delete;
BuildService& operator=(const BuildService&) = delete; BuildService& operator=(const BuildService&) = delete;
void scaffold(const std::string& name, int dimension); 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_build();
std::string start_cook(Json resolved_scene); std::string start_cook(Json resolved_scene);
// Publishes output/generations/<id>; current.json changes only after all validation succeeds. // 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; std::string name;
int dimension = 3; int dimension = 3;
bool create = false; bool create = false;
std::string language = "cpp";
}; };
// Records only existing, valid projects. Call after successful Session setup. // Records only existing, valid projects. Call after successful Session setup.
void remember_project(const std::filesystem::path& project); void remember_project(const std::filesystem::path& project);
@@ -15,7 +16,9 @@ class ProjectLauncher {
public: public:
ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root, ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {}, 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(); ~ProjectLauncher();
void frame(const std::vector<render::Event>&); void frame(const std::vector<render::Event>&);
const render::Snapshot& snapshot() const; const render::Snapshot& snapshot() const;
@@ -30,5 +33,7 @@ class ProjectLauncher {
std::optional<ProjectSelection> std::optional<ProjectSelection>
run_project_launcher(const std::filesystem::path& engine_root, run_project_launcher(const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {}, 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 } // namespace faset::editor
+2
View File
@@ -6,6 +6,7 @@
#include <faset/editor/plugins.hpp> #include <faset/editor/plugins.hpp>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <string_view>
#include <thread> #include <thread>
namespace faset::editor { namespace faset::editor {
@@ -33,6 +34,7 @@ class Session {
return plugins_ ? plugins_->panels() : Json::array(); return plugins_ ? plugins_->panels() : Json::array();
} }
void scaffold(const std::string& name, int dimension); void scaffold(const std::string& name, int dimension);
void scaffold(const std::string& name, int dimension, std::string_view language);
const SessionConfig& config() const { const SessionConfig& config() const {
return config_; return config_;
} }
+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
+147
View File
@@ -0,0 +1,147 @@
#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(), '\\', '/');
path = std::filesystem::path(path).lexically_normal().generic_string();
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 : std::filesystem::path(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 <atomic>
#include <cctype> #include <cctype>
#include <chrono> #include <chrono>
@@ -6,9 +7,12 @@
#include <faset/assets/asset_data.hpp> #include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp> #include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp> #include <faset/authoring/schema.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/core/process.hpp> #include <faset/core/process.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_diagnostics.hpp>
#include <faset/editor/build_service.hpp> #include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp> #include <faset/scripting/project.hpp>
#include <fstream> #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); 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 } // namespace
Json JobStatus::json() const { Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state}, return {{"id", id}, {"kind", kind}, {"state", state},
{"stage", stage}, {"progress", progress}, {"log", log}, {"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) { void write_cooked_scene(const fs::path& path, const Json& scene) {
validate_scene(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) { std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
if (job.cancelled) if (job.cancelled)
throw 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 = "$"; std::string description = "$";
for (const auto& argument : arguments) for (const auto& argument : arguments)
description += " " + Json(argument).dump(); description += " " + Json(argument).dump();
@@ -205,6 +312,35 @@ struct BuildService::Impl {
log(job, description); log(job, description);
Process process({std::move(arguments), cwd, {}}); Process process({std::move(arguments), cwd, {}});
std::string output; 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) { while (true) {
if (job.cancelled) { if (job.cancelled) {
process.cancel(); process.cancel();
@@ -214,14 +350,29 @@ struct BuildService::Impl {
} }
auto poll = process.poll(); auto poll = process.poll();
log(job, poll.output); log(job, poll.output);
collect_diagnostics(poll.output, !poll.running);
output += poll.output; output += poll.output;
if (output.size() > max_log_bytes) if (output.size() > max_log_bytes)
output.erase(0, output.size() - max_log_bytes); output.erase(0, output.size() - max_log_bytes);
if (!poll.running) { 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 " + throw std::runtime_error("Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) + std::to_string(poll.exit_code.value_or(1)) +
"; see job log"); "; see job log");
}
return output; return output;
} }
std::this_thread::sleep_for(std::chrono::milliseconds(15)); std::this_thread::sleep_for(std::chrono::milliseconds(15));
@@ -240,19 +391,23 @@ struct BuildService::Impl {
} }
} }
Json build(Job& job, bool exporting = false) { 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& configuration = exporting ? config.export_configuration : config.configuration;
const auto native_directory = config.build_directory / configuration; const auto native_directory = config.build_directory / configuration;
checkpoint(job, "Configuring gameplay", .05); checkpoint(job, "Configuring gameplay", .05);
job.lua = scripting::loadLuaProject(config.project_root); job.lua = scripting::loadLuaProject(config.project_root);
const auto cpp = config.project_root / "Scripts" / "Gameplay.cpp"; const auto inputs = capture_build_inputs(config, job.lua);
const auto hpp = config.project_root / "Scripts" / "Gameplay.hpp"; 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); 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())) if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled()))
throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, " throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, "
"or Lua entry scripts declared in project.faset.json"); "or Lua entry scripts declared in project.faset.json");
const auto cpp_source = has_cpp ? read_text(cpp) : std::string{}; ensure_native_toolchain_stamp(config, native_directory, inputs);
const auto hpp_source = has_hpp ? read_text(hpp) : std::string{};
fs::create_directories(native_directory);
std::vector<std::string> arguments = {config.cmake, std::vector<std::string> arguments = {config.cmake,
"-S", "-S",
path_to_utf8(config.engine_root), path_to_utf8(config.engine_root),
@@ -267,37 +422,100 @@ struct BuildService::Impl {
"-DFASET_BUILD_RUNTIME=ON", "-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON", "-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" + "-DFASET_GAMEPLAY_SOURCE_DIR=" +
path_to_utf8(config.project_root / "Scripts")}; path_to_utf8(staged_scripts)};
bool compiler_overridden = false; if (!configured_cmake_value(config, "CMAKE_C_COMPILER")) {
for (const auto& arg : config.configure_arguments)
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
compiler_overridden = true;
if (!compiler_overridden) {
#ifdef _WIN32 #ifdef _WIN32
auto compiler = find_executable("clang-cl"); auto compiler = find_executable("clang-cl");
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler)); arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler));
#else #else
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang"))); 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++"))); arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
#endif #endif
} }
arguments.insert(arguments.end(), config.configure_arguments.begin(), for (const auto& argument : config.configure_arguments) {
config.configure_arguments.end()); 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); arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
// Project declarations, not a stale cache or a user-supplied override, determine // Project declarations, not a stale cache or a user-supplied override, determine
// whether the packaged game has a Lua VM linked into it. // whether the packaged game has a Lua VM linked into it.
arguments.push_back(std::string("-DFASET_ENABLE_LUA=") + arguments.push_back(std::string("-DFASET_ENABLE_LUA=") +
(job.lua.enabled() ? "ON" : "OFF")); (job.lua.enabled() ? "ON" : "OFF"));
run(job, std::move(arguments), config.project_root); run(job, std::move(arguments), config.project_root);
const auto configured = std::chrono::steady_clock::now();
checkpoint(job, "Compiling and linking Player", .25); checkpoint(job, "Compiling and linking Player", .25);
run(job, run(job,
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration, {config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"}, "--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root); 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 player = build_executable(native_directory, configuration, "faset_player");
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter"); 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 staging = config.cache_root / "builds" / (".staging-" + job.status.id);
const auto generation = config.cache_root / "builds" / job.status.id; const auto generation = config.cache_root / "builds" / job.status.id;
fs::create_directories(staging); fs::create_directories(staging);
@@ -322,10 +540,7 @@ struct BuildService::Impl {
// Validate the complete metadata before publishing either the schema or // Validate the complete metadata before publishing either the schema or
// its Player generation. Session uses this same authoring contract. // its Player generation. Session uses this same authoring contract.
(void)authoring::gameplay_schemas(schema); (void)authoring::gameplay_schemas(schema);
std::string fingerprint = sha256_file(player) + sha256_file(exporter) + const auto fingerprint = package_key;
read_text(native_directory / "CMakeCache.txt");
fingerprint += cpp_source + hpp_source + job.lua.fingerprint;
fingerprint = sha256(fingerprint);
schema["build_fingerprint"] = fingerprint; schema["build_fingerprint"] = fingerprint;
if (job.lua.enabled()) if (job.lua.enabled())
schema["lua_fingerprint"] = job.lua.fingerprint; schema["lua_fingerprint"] = job.lua.fingerprint;
@@ -341,36 +556,39 @@ struct BuildService::Impl {
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"}) "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file); copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration); 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"}, Json manifest{{"format", "faset.build"},
{"version", 1}, {"version", 2},
{"id", job.status.id}, {"id", job.status.id},
{"fingerprint", fingerprint}, {"fingerprint", fingerprint},
{"package_key", package_key},
{"configuration", configuration}, {"configuration", configuration},
{"lua_enabled", job.lua.enabled()}, {"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint}, {"lua_fingerprint", job.lua.fingerprint},
{"player", "faset_player" + executable_suffix()}, {"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"}}; {"schema", "schema.json"},
{"files", files}};
atomic_write_json(staging / "manifest.json", manifest); atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68); checkpoint(job, "Publishing build generation", .68);
if (job.lua.enabled() && if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint) scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot changed during schema export"); throw std::runtime_error("Lua build snapshot changed during schema export");
if (scripting::loadLuaProject(config.project_root).fingerprint != job.lua.fingerprint || source_unchanged();
has_cpp != fs::is_regular_file(cpp) || has_hpp != fs::is_regular_file(hpp) || if (!validate_build_generation(staging, package_key))
(has_cpp && (read_text(cpp) != cpp_source || read_text(hpp) != hpp_source))) throw std::runtime_error("Build generation failed final integrity validation");
throw std::runtime_error("Gameplay sources changed during the build; build again");
fs::rename(staging, generation); fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json", atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}}); {{"generation", job.status.id}, {"fingerprint", fingerprint}});
return {{"generation", job.status.id}, return result_for(job.status.id, fingerprint, false);
{"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}};
} catch (...) { } catch (...) {
std::error_code error; std::error_code error;
fs::remove_all(staging, 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")) if (!fs::exists(c.project_root / ".gitignore"))
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n"); 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() { std::string BuildService::start_build() {
return impl_->enqueue("build"); return impl_->enqueue("build");
} }
+100 -57
View File
@@ -2,6 +2,7 @@
#include <chrono> #include <chrono>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/session.hpp> #include <faset/editor/session.hpp>
#include <faset/scripting/project.hpp> #include <faset/scripting/project.hpp>
@@ -112,6 +113,10 @@ void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension); builds_.scaffold(name, dimension);
log("Created project: " + name); 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 Session::assets_list() const {
Json list = Json::array(); Json list = Json::array();
const auto directory = assets_.cache_root() / "assets"; const auto directory = assets_.cache_root() / "assets";
@@ -132,20 +137,7 @@ Json Session::assets_list() const {
} }
std::string Session::source_signature() const { std::string Session::source_signature() const {
const auto lua = scripting::loadLuaProject(config_.project_root); const auto lua = scripting::loadLuaProject(config_.project_root);
const auto directory = config_.project_root / "Scripts"; return gameplay_source_hash(config_.project_root, lua);
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);
} }
Json Session::schema_status() const { Json Session::schema_status() const {
try { try {
@@ -514,51 +506,102 @@ void Session::register_commands() {
{"configuration_created", create_configuration}, {"configuration_created", create_configuration},
{"scripts_configuration_created", create_scripts_configuration}}; {"scripts_configuration_created", create_scripts_configuration}};
}); });
commands_.add( const auto open_source = [this](const Json& args, bool lua_only) -> Json {
"faset_script_open", const auto path_error = lua_only ? "lua.source" : "source.path";
"Open a project Lua source in an external editor, never as an executable. Optional editor " const auto editor_error = lua_only ? "lua.editor" : "source.editor";
"is an argv array (default: project editor.script_editor, then zed). Exact {file} and " const auto requested = path_from_utf8(args.at("path").get<std::string>());
"{project} arguments are replaced; no shell expansion is performed.", require(!requested.empty() && !requested.is_absolute(), path_error,
schema({{"path", text}, {"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}}, "Choose an existing source inside Scripts");
{"path"}), for (const auto& part : requested)
[&](const Json& args) { require(part != "..", path_error, "Source path cannot traverse outside Scripts");
const auto file = project_path(config_.project_root, std::filesystem::path file;
path_from_utf8(args.at("path").get<std::string>())); try {
require(file.extension() == ".lua" && std::filesystem::is_regular_file(file), file = project_path(config_.project_root, requested);
"lua.source", "Select an existing .lua file inside the project"); } catch (const std::exception&) {
Json command = Json::array({"zed", "{file}"}); throw Error(path_error, "Source path must stay inside project Scripts");
const auto settings = project(); }
if (settings.contains("editor") && settings.at("editor").is_object() && const auto project_root = std::filesystem::weakly_canonical(config_.project_root);
settings.at("editor").contains("script_editor")) const auto relative = file.lexically_relative(project_root);
command = settings.at("editor").at("script_editor"); const auto name = generic_path_to_utf8(relative);
if (args.contains("editor")) const auto extension = path_to_utf8(file.extension());
command = args.at("editor"); const bool supported = extension == ".cpp" || extension == ".hpp" ||
require(command.is_array() && !command.empty(), "lua.editor", extension == ".h" || extension == ".cc" ||
"Configure editor.script_editor as a nonempty executable/argument array"); extension == ".cxx" || extension == ".lua";
std::vector<std::string> arguments; require(name.starts_with("Scripts/") && supported &&
bool has_file = false; (!lua_only || extension == ".lua") &&
for (const auto& part : command) { std::filesystem::is_regular_file(file),
require(part.is_string(), "lua.editor", "Editor arguments must be strings"); path_error, "Choose an existing source file inside Scripts");
auto argument = part.get<std::string>(); const auto line = args.value("line", 1);
require(argument.find('\0') == std::string::npos, "lua.editor", const auto column = args.value("column", 1);
"Editor arguments cannot contain NUL"); require(line > 0 && column > 0, path_error, "Source line and column must be positive");
if (argument == "{file}") { Json command = Json::array({"zed", "{file}:{line}:{column}"});
require(!arguments.empty(), "lua.editor", "The first argument is the editor"); const auto settings = project();
argument = path_to_utf8(file); if (settings.contains("editor") && settings.at("editor").is_object() &&
has_file = true; settings.at("editor").contains("script_editor"))
} else if (argument == "{project}") { command = settings.at("editor").at("script_editor");
require(!arguments.empty(), "lua.editor", "The first argument is the editor"); if (args.contains("editor"))
argument = path_to_utf8(std::filesystem::absolute(config_.project_root)); command = args.at("editor");
} require(command.is_array() && !command.empty(), editor_error,
arguments.push_back(std::move(argument)); "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"); return found;
if (!has_file) };
arguments.push_back(path_to_utf8(file)); 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); launch_detached(arguments, config_.project_root);
log("Opened Lua source in external editor: " + args.at("path").get<std::string>()); } catch (const std::exception& error) {
return Json{{"opened", args.at("path")}}; 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", commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative " "Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.", "output directory. Returns a job ID.",
+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;
}
}
+75
View File
@@ -0,0 +1,75 @@
#include <faset/editor/build_diagnostics.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 = fs::path(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"}; config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"};
editor::BuildService builds(config); editor::BuildService builds(config);
builds.scaffold("Schema publication", 2); 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()); authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas());
const auto valid = manifest(); const auto valid = manifest();
atomic_write_json(config.project_root / "schema-fixture.json", valid); 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); 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>()); const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain", for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"}) "gpuHzbMain", "gpuPostCullMain"})
@@ -161,6 +222,34 @@ int test_main(int argc, char** argv) {
const auto previous_player = sha256_file(player); const auto previous_player = sha256_file(player);
const auto previous_schema = read_text(schema); const auto previous_schema = read_text(schema);
const auto previous_manifest = read_text(directory / "manifest.json"); const auto previous_manifest = read_text(directory / "manifest.json");
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>() && check(!first.result.at("lua_enabled").get<bool>() &&
!fs::exists(directory / "project.faset.json"), !fs::exists(directory / "project.faset.json"),
"C++-only build publishes no Lua sources or project manifest"); "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"); "C++-only build explicitly disables the Lua VM in CMake");
authoring.replace_external_schemas(read_json(schema)); authoring.replace_external_schemas(read_json(schema));
const auto previous_registry = authoring.schemas().manifest(); const auto previous_registry = authoring.schemas().manifest();
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, check(authoring.schemas().schema("game.mover").at("version") == 2,
"Matching custom v2 metadata reaches authoring"); "Matching custom v2 metadata reaches authoring");
migration_contracts(config.project_root / "migration-contracts", read_json(schema)); 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{}; std::size_t generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) { for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
++generations; ++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{}; bool rejected{};
try { try {
authoring.replace_external_schemas(invalid[index]); 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"); "Later edits never mutate an already published Lua generation");
const auto lua_pointer = read_text(last_build); const auto lua_pointer = read_text(last_build);
for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) { 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"); atomic_write(config.project_root / marker, "fixture\n");
const auto raced = builds.wait(builds.start_build()); const auto raced = builds.wait(builds.start_build());
check(raced.state == "failed" && read_text(last_build) == lua_pointer, 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) { int tool_main(int argc, char** argv) {
try { try {
if (argc >= 3 && std::string_view(argv[1]) == "--output") { if (argc >= 3 && std::string_view(argv[1]) == "--output") {
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") { if (argc == 5 && std::string_view(argv[3]) == "--project") {
const auto snapshot = path_from_utf8(argv[4]); const auto snapshot = path_from_utf8(argv[4]);
const auto project = read_json(snapshot / "project.faset.json"); 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")) if (fs::exists("mutate-lua-snapshot"))
atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n"); 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")); atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
return 0; return 0;
} }
@@ -34,8 +39,18 @@ int tool_main(int argc, char** argv) {
std::cout << "Native packaging fixture validated\n"; std::cout << "Native packaging fixture validated\n";
return 0; 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; return 0;
}
fs::path build; fs::path build;
for (int i = 1; i + 1 < argc; ++i) for (int i = 1; i + 1 < argc; ++i)
if (std::string_view(argv[i]) == "-B") if (std::string_view(argv[i]) == "-B")
+101
View File
@@ -1,8 +1,12 @@
#include "assets_image_fixtures.hpp" #include "assets_image_fixtures.hpp"
#include <algorithm>
#include <atomic>
#include <bit> #include <bit>
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <functional>
#include <faset/assets/asset_pipeline.hpp> #include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/core/process.hpp> #include <faset/core/process.hpp>
@@ -42,6 +46,102 @@ Json scene(int dimension) {
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()}, {"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}}; {"instances", Json::array()}};
} }
void 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) { void lua_project_contracts(const fs::path& root) {
fs::create_directories(root); fs::create_directories(root);
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency"); require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
@@ -379,6 +479,7 @@ int test_main(int argc, char** argv) {
try { try {
fs::create_directories(temporary); fs::create_directories(temporary);
lua_project_contracts(temporary / "lua-project"); lua_project_contracts(temporary / "lua-project");
starter_contracts(temporary / "starters");
const auto original_executable = fs::absolute(path_from_utf8(argv[0])); const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
const auto executable = temporary / original_executable.filename(); const auto executable = temporary / original_executable.filename();
fs::copy_file(original_executable, executable); fs::copy_file(original_executable, executable);
+36 -1
View File
@@ -1,9 +1,18 @@
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/editor/session.hpp> #include <faset/editor/session.hpp>
#include <chrono>
#include <iostream> #include <iostream>
#include <thread>
int main() { int main(int argc, char** argv) {
using namespace faset; 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() / const auto root = std::filesystem::temp_directory_path() /
path_from_utf8("Faset Café 世界 session " + new_id()); path_from_utf8("Faset Café 世界 session " + new_id());
try { try {
@@ -115,6 +124,32 @@ int main() {
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor"); {{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}}, reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
"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));
require(opened.at("path") == "Scripts/Gameplay.cpp" && opened.at("line") == 17 &&
std::filesystem::is_regular_file(probe) &&
read_json(probe) ==
Json::array({path_to_utf8(root / "Scripts/Gameplay.cpp") + ":17:4",
path_to_utf8(root)}),
"test", "Source navigation passes a Unicode path and location as literal argv");
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(); auto bad_lua_project = session.project();
bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}}; bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}};
atomic_write_json(root / "project.faset.json", bad_lua_project); atomic_write_json(root / "project.faset.json", bad_lua_project);
+17
View File
@@ -111,6 +111,9 @@ int main() {
text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename())); text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename()));
click(launcher, "launcher-2d"); click(launcher, "launcher-2d");
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection"); 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.render(launcher.snapshot());
renderer.capture(root / "launcher-create.ppm"); renderer.capture(root / "launcher-create.ppm");
launcher.frame({key("Return", true)}); launcher.frame({key("Return", true)});
@@ -118,6 +121,7 @@ int main() {
"Create through keyboard rejected: " + "Create through keyboard rejected: " +
launcher.widgets().find("launcher-error")->text); launcher.widgets().find("launcher-error")->text);
check(launcher.selection()->create && launcher.selection()->dimension == 2 && check(launcher.selection()->create && launcher.selection()->dimension == 2 &&
launcher.selection()->language == "lua" &&
launcher.selection()->name == "Тестовый проект", launcher.selection()->name == "Тестовый проект",
"Create through keyboard preserves typed project metadata"); "Create through keyboard preserves typed project metadata");
check(launcher.selection()->path == new_project, check(launcher.selection()->path == new_project,
@@ -127,6 +131,19 @@ int main() {
check(!std::filesystem::exists(new_project), check(!std::filesystem::exists(new_project),
"Launcher does not create a partial project before Session scaffold"); "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), editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE),
root / "Missing", recents); root / "Missing", recents);
+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