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/session.hpp>
#include <iostream>
#include <memory>
#include <thread>
#ifdef FASET_HAS_EDITOR_UI
#include <faset/editor/editor_ui.hpp>
@@ -40,7 +41,7 @@ void help() {
std::cout
<< "Faset Editor\n"
" faset_editor Open the project launcher\n"
" faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n"
" faset_editor --project PATH [--new NAME --dimension 2|3 [--language cpp|lua]] [--scene RELATIVE_PATH]\n"
" faset_editor --project PATH --mcp [--gui]\n"
" faset_editor --project PATH --command JSON [--wait]\n"
"Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n"
@@ -52,9 +53,10 @@ int editor_main(int argc, char** argv) {
using namespace faset::editor;
try {
std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture;
std::string new_name, command;
std::string new_name, command, language = "cpp";
int dimension = 3;
bool mcp = false, gui = true, explicit_gui = false, wait = false;
bool explicit_language = false;
std::uint64_t frames = 0;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
@@ -74,6 +76,10 @@ int editor_main(int argc, char** argv) {
new_name = value();
else if (arg == "--dimension")
dimension = std::stoi(value());
else if (arg == "--language") {
language = value();
explicit_language = true;
}
else if (arg == "--scene")
scene = path_from_utf8(value());
else if (arg == "--mcp")
@@ -100,30 +106,65 @@ int editor_main(int argc, char** argv) {
throw Error("cli.option", "Unknown option: " + arg);
}
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command");
require(!explicit_language || !new_name.empty(), "cli.language",
"--language requires --new NAME");
require(language == "cpp" || language == "lua", "cli.language",
"Language must be cpp or lua");
if (mcp && !explicit_gui)
gui = false;
require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project",
"Use --project PATH for command, MCP, or --new modes");
auto previous_project = project;
#ifdef FASET_HAS_EDITOR_UI
std::optional<ProjectSelection> retry_create;
std::string creation_error;
#endif
for (;;) {
if (project.empty()) {
#ifdef FASET_HAS_EDITOR_UI
const auto selection =
run_project_launcher(engine, previous_project, frames, capture);
run_project_launcher(engine, previous_project, frames, capture,
retry_create, creation_error);
retry_create.reset();
creation_error.clear();
if (!selection)
return 0;
project = selection->path;
new_name = selection->create ? selection->name : std::string();
dimension = selection->dimension;
language = selection->language;
explicit_language = selection->create;
#else
throw Error("editor.gui_unavailable",
"This build has no graphical project launcher; use --project PATH");
#endif
}
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
executable_directory(argv[0])});
if (!new_name.empty())
session.scaffold(new_name, dimension);
std::unique_ptr<Session> session_holder;
try {
session_holder = std::make_unique<Session>(SessionConfig{
std::filesystem::absolute(project), std::filesystem::absolute(engine),
executable_directory(argv[0])});
if (!new_name.empty()) {
if (explicit_language)
session_holder->scaffold(new_name, dimension, language);
else
session_holder->scaffold(new_name, dimension);
}
} catch (const std::exception& error) {
#ifdef FASET_HAS_EDITOR_UI
if (gui && !new_name.empty()) {
retry_create = ProjectSelection{std::filesystem::absolute(project),
new_name, dimension, true, language};
creation_error = error.what();
project.clear();
new_name.clear();
scene.clear();
continue;
}
#endif
throw;
}
Session& session = *session_holder;
const auto settings = session.project();
#ifdef FASET_HAS_EDITOR_UI
if (gui && std::filesystem::exists(project / "project.faset.json"))
@@ -167,6 +208,8 @@ int editor_main(int argc, char** argv) {
project.clear();
scene.clear();
new_name.clear();
explicit_language = false;
language = "cpp";
continue;
#else
throw Error(
+52 -10
View File
@@ -75,6 +75,22 @@ ProjectSelection open_project(const fs::path& path) {
throw std::runtime_error("Project name or scene type is invalid.");
return {path, std::move(name), dimension, false};
}
bool fresh_destination(const fs::path& path) {
if (!fs::exists(path))
return true;
if (!fs::is_directory(path) || fs::is_symlink(path))
return false;
for (const auto& entry : fs::directory_iterator(path)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
return false;
for (const auto& internal : fs::directory_iterator(entry.path()))
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
return false;
}
return true;
}
Json recent_records(const fs::path& file) {
try {
auto j = read_json(file);
@@ -124,9 +140,11 @@ struct ProjectLauncher::Impl {
std::optional<ProjectSelection> selected;
bool create = false, cancelled = false, browsing = false, browser_valid = false;
int dimension = 3;
std::string language = "cpp";
std::string error;
Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial,
const fs::path& recents)
const fs::path& recents, const std::optional<ProjectSelection>& retry,
std::string creation_error)
: renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
recents_file(recents.empty() ? recent_path() : recents) {
auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
@@ -147,13 +165,22 @@ struct ProjectLauncher::Impl {
ui.set_ime(
[this](bool enabled) { renderer.set_text_input(enabled); },
[this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); });
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
const auto start = retry ? retry->path
: initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
ui.update_text("launcher-path", path_to_utf8(start));
create = initial.empty();
create = retry ? retry->create : initial.empty();
if (retry) {
ui.update_text("launcher-name", retry->name);
dimension = retry->dimension;
language = retry->language;
error = std::move(creation_error);
}
ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); };
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; };
ui.find("launcher-cpp")->on_click = [this](Widget&) { language = "cpp"; };
ui.find("launcher-lua")->on_click = [this](Widget&) { language = "lua"; };
ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); };
ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; };
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); };
@@ -209,14 +236,14 @@ struct ProjectLauncher::Impl {
throw std::runtime_error("Enter a project name.");
if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos)
throw std::runtime_error("Use a short project name on one line.");
if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(path)))
if (!fresh_destination(path))
throw std::runtime_error("Create requires a new or empty directory.");
auto parent = path.parent_path();
while (!parent.empty() && !fs::exists(parent))
parent = parent.parent_path();
if (parent.empty() || !fs::is_directory(parent))
throw std::runtime_error("Project parent directory is unavailable.");
selected = ProjectSelection{path, name, dimension, true};
selected = ProjectSelection{path, name, dimension, true, language};
} else
selected = open_project(path);
error.clear();
@@ -351,14 +378,24 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->selected = create;
ui.find("launcher-2d")->selected = dimension == 2;
ui.find("launcher-3d")->selected = dimension == 3;
ui.find("launcher-cpp")->selected = language == "cpp";
ui.find("launcher-lua")->selected = language == "lua";
ui.find("launcher-2d")->appearance =
dimension == 2 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-3d")->appearance =
dimension == 3 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-cpp")->appearance =
language == "cpp" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-lua")->appearance =
language == "lua" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-language-hint")->text =
language == "cpp" ? "Compiled gameplay. Add Lua later if you need quick reloads."
: "Lua gameplay reloads during development without a C++ rebuild.";
for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label",
"launcher-dimensions", "launcher-name-group",
"launcher-name-space", "launcher-path-space",
"launcher-dimension-group"})
"launcher-dimension-group", "launcher-language-group",
"launcher-language-space"})
ui.find(id)->visible = create;
ui.find("launcher-heading")->text = create ? "Create project" : "Open project";
ui.find("launcher-submit")->text = create ? "Create project" : "Open project";
@@ -424,8 +461,11 @@ struct ProjectLauncher::Impl {
}
};
ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine,
const fs::path& initial, const fs::path& recents)
: impl_(std::make_unique<Impl>(r, engine, initial, recents)) {}
const fs::path& initial, const fs::path& recents,
const std::optional<ProjectSelection>& retry,
std::string creation_error)
: impl_(std::make_unique<Impl>(r, engine, initial, recents, retry,
std::move(creation_error))) {}
ProjectLauncher::~ProjectLauncher() = default;
void ProjectLauncher::frame(const std::vector<render::Event>& events) {
impl_->frame(events);
@@ -445,9 +485,11 @@ bool ProjectLauncher::cancelled() const {
std::optional<ProjectSelection> run_project_launcher(const fs::path& engine,
const fs::path& initial,
std::uint64_t max_frames,
const fs::path& capture) {
const fs::path& capture,
const std::optional<ProjectSelection>& retry,
std::string creation_error) {
render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true});
ProjectLauncher launcher(renderer, engine, initial);
ProjectLauncher launcher(renderer, engine, initial, {}, retry, std::move(creation_error));
std::uint64_t frames = 0;
while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() &&
(max_frames == 0 || frames < max_frames)) {
+9
View File
@@ -41,6 +41,15 @@
{"id": "launcher-3d", "kind": "button", "text": "3D", "layout": {"width": 116}}
]}
]},
{"id": "launcher-language-space", "kind": "row", "layout": {"height": 18}},
{"id": "launcher-language-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-language-label", "kind": "label", "text": "Gameplay language", "layout": {"height": 24}},
{"id": "launcher-languages", "kind": "row", "layout": {"height": 42, "gap": 4}, "children": [
{"id": "launcher-cpp", "kind": "button", "text": "C++", "layout": {"width": 116}},
{"id": "launcher-lua", "kind": "button", "text": "Lua", "layout": {"width": 116}}
]},
{"id": "launcher-language-hint", "kind": "label", "text": "Compiled gameplay. Add Lua later if you need quick reloads.", "font_size": 12, "layout": {"height": 30}}
]},
{"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "font_size": 13, "layout": {"height": 36}},
{"id": "launcher-actions-space", "kind": "row", "layout": {"height": 22}},
{"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}},
+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_compile_features(faset_build_service PUBLIC cxx_std_20)
target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads)
if(BUILD_TESTING)
add_executable(faset_build_diagnostics_tests ${PROJECT_SOURCE_DIR}/tests/build_diagnostics_tests.cpp)
target_link_libraries(faset_build_diagnostics_tests PRIVATE faset_build_service)
add_test(NAME build_diagnostics COMMAND faset_build_diagnostics_tests)
add_executable(faset_build_cache_tests ${PROJECT_SOURCE_DIR}/tests/build_cache_tests.cpp)
target_link_libraries(faset_build_cache_tests PRIVATE faset_build_service)
target_compile_definitions(faset_build_cache_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
add_test(NAME build_cache COMMAND faset_build_cache_tests)
add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp)
target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets)
target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
+13 -3
View File
@@ -31,17 +31,27 @@ cmake --build --preset linux-debug --parallel
ctest --preset linux-debug
```
Create a project and open the native Editor:
Create a runnable C++ or Lua starter and open the native Editor:
```sh
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3 --language cpp
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
```
You can also run `build/linux-debug/faset_editor` without arguments to open the
project launcher and create or select a project using the native interface.
project launcher. Its Create view offers C++ 2D, C++ 3D, Lua 2D, and Lua 3D.
Each explicit starter includes `Scenes/main.scene.json` with a player, ground,
and a behavior bound to `Scripts/Gameplay.cpp` or `Scripts/main.lua`. 3D starters
also include a camera and light. Existing files in a destination directory are
never replaced by starter creation. The older `--new NAME --dimension 2|3`
command without `--language` retains its original scene-free C++ scaffold.
Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
The Player runs separately. Stop it before changing and rebuilding C++ gameplay.
Lua starters declare their entry script in `project.faset.json`, install LuaLS
annotations/configuration, and need no `Gameplay.cpp`. Use **Refresh Lua** after
editing a Lua behavior declaration; development **Reload Lua** can apply a valid
script edit to a running Player without recompiling C++.
See [MCP and CLI](../editor/mcp.md) for headless authoring and automation.
For an optimized build use `linux-release`. The `linux-sanitize` preset enables
+17
View File
@@ -5,6 +5,23 @@ behaviors share the same runtime lifecycle, typed entity operations, scene compo
and Inspector metadata. The Editor does not run gameplay code in its own process.
There is no built-in script editor: edit `.lua` files in Zed or another external editor.
## Start with a runnable Lua project
Choose **Create project → Lua → 2D or 3D** in the native launcher, or run:
```sh
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
```
The starter creates `Scripts/main.lua`, a scene with a player and ground, and a
manifest entry for that script. The player moves horizontally and jumps with the
default input actions. The 3D choice also provides a camera and directional light.
Open `Scripts/main.lua` in your external editor and change `speed` or
`fixed_update`. **Refresh Lua** updates declared Inspector fields; **Reload Lua**
updates a development Player after a valid edit without rebuilding native gameplay.
The starter installs LuaLS declarations and `.luarc.json`; reopening it in Zed or
another LuaLS-enabled editor provides completions for the Faset API.
## Enable Lua in a project
Add explicit entry scripts to `project.faset.json`:
@@ -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 <memory>
#include <string>
#include <string_view>
#include <vector>
namespace faset::editor {
@@ -22,6 +23,7 @@ struct JobStatus {
std::string id, kind, state{"queued"}, stage{"queued"};
double progress{};
std::string log, error;
Json diagnostics = Json::array();
Json result = Json::object();
bool finished() const {
return state == "succeeded" || state == "failed" || state == "cancelled";
@@ -37,6 +39,9 @@ class BuildService {
BuildService(const BuildService&) = delete;
BuildService& operator=(const BuildService&) = delete;
void scaffold(const std::string& name, int dimension);
// Explicit starter choice creates a runnable start scene. The legacy overload above
// remains intentionally scene-free for existing command-line and API callers.
void scaffold(const std::string& name, int dimension, std::string_view language);
std::string start_build();
std::string start_cook(Json resolved_scene);
// Publishes output/generations/<id>; current.json changes only after all validation succeeds.
+7 -2
View File
@@ -8,6 +8,7 @@ struct ProjectSelection {
std::string name;
int dimension = 3;
bool create = false;
std::string language = "cpp";
};
// Records only existing, valid projects. Call after successful Session setup.
void remember_project(const std::filesystem::path& project);
@@ -15,7 +16,9 @@ class ProjectLauncher {
public:
ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {},
const std::filesystem::path& recent_store = {});
const std::filesystem::path& recent_store = {},
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
~ProjectLauncher();
void frame(const std::vector<render::Event>&);
const render::Snapshot& snapshot() const;
@@ -30,5 +33,7 @@ class ProjectLauncher {
std::optional<ProjectSelection>
run_project_launcher(const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {},
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {});
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {},
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
} // namespace faset::editor
+2
View File
@@ -6,6 +6,7 @@
#include <faset/editor/plugins.hpp>
#include <memory>
#include <mutex>
#include <string_view>
#include <thread>
namespace faset::editor {
@@ -33,6 +34,7 @@ class Session {
return plugins_ ? plugins_->panels() : Json::array();
}
void scaffold(const std::string& name, int dimension);
void scaffold(const std::string& name, int dimension, std::string_view language);
const SessionConfig& config() const {
return config_;
}
+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 <cctype>
#include <chrono>
@@ -6,9 +7,12 @@
#include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_diagnostics.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <fstream>
@@ -89,11 +93,107 @@ fs::path build_executable(const fs::path& build, const std::string& configuratio
}
throw std::runtime_error("Build did not produce " + target);
}
Json starter_scene(const std::string& name, int dimension, std::string_view language) {
const auto schemas = authoring::builtin_schemas();
auto scene = authoring::make_scene(name + " — Starter", dimension);
scene["simulation"] = authoring::default_simulation_settings();
auto add_component = [&](Json& entity, std::string type, Json fields) {
entity["components"].push_back({{"id", new_id()},
{"type", std::move(type)},
{"version", 1},
{"fields", std::move(fields)}});
};
auto ground = authoring::make_entity(schemas, "Ground");
auto& ground_pose = ground["components"][0]["fields"];
ground_pose["position"] = {0, -1, 0};
ground_pose["scale"] = dimension == 2 ? Json::array({12, 1, 1}) : Json::array({12, 1, 12});
if (dimension == 2) {
add_component(ground, "faset.sprite", {{"size", {1, 1}},
{"color", {0.20, 0.25, 0.31, 1}}});
} else {
add_component(ground, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.20, 0.25, 0.31, 1}}});
}
auto ground_body = schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) +
"d");
ground_body["body_type"] = "static";
add_component(ground, "faset.rigid_body_" + std::to_string(dimension) + "d",
std::move(ground_body));
scene["entities"].push_back(std::move(ground));
auto actor = authoring::make_entity(schemas, "Player");
actor["components"][0]["fields"]["position"] = {0, 1, 0};
if (dimension == 2) {
add_component(actor, "faset.sprite", {{"size", {0.8, 0.8}},
{"color", {0.25, 0.72, 0.85, 1}}});
} else {
actor["components"][0]["fields"]["scale"] = {0.8, 0.8, 0.8};
add_component(actor, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.25, 0.72, 0.85, 1}}});
}
add_component(actor, "faset.rigid_body_" + std::to_string(dimension) + "d",
schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) + "d"));
add_component(actor, language == "lua" ? "starter.player" : "gameplay.character",
{{"speed", 4.0}, {"jump_speed", 5.0}});
scene["entities"].push_back(std::move(actor));
if (dimension == 3) {
auto camera = authoring::make_entity(schemas, "Camera");
camera["components"][0]["fields"]["position"] = {0, 2.5, 8};
camera["components"][0]["fields"]["rotation"] = {-0.18, 0, 0};
add_component(camera, "faset.camera", schemas.default_fields("faset.camera"));
scene["entities"].push_back(std::move(camera));
auto sun = authoring::make_entity(schemas, "Sun");
add_component(sun, "faset.light", schemas.default_fields("faset.light"));
scene["entities"].push_back(std::move(sun));
}
// Validate every built-in component now; the behavior component is validated by
// SchemaExporter from the selected C++/Lua module during the first build.
auto builtins = scene;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [](const Json& component) {
return !component.at("type").get<std::string>().starts_with("faset.");
});
}
authoring::validate_scene(builtins, schemas);
return scene;
}
struct StarterCreateLock {
fs::path path;
explicit StarterCreateLock(const fs::path& root) : path(root / ".faset/starter-create.lock") {
if (!fs::create_directory(path))
throw std::runtime_error("Another project creation is in progress; remove a stale " +
path_to_utf8(path) + " only after closing that Editor");
}
~StarterCreateLock() {
std::error_code ignored;
fs::remove_all(path, ignored);
}
StarterCreateLock(const StarterCreateLock&) = delete;
StarterCreateLock& operator=(const StarterCreateLock&) = delete;
};
void require_new_starter_directory(const fs::path& root) {
// BuildService creates .faset/cache in its constructor. Any other content is
// user-owned, including a previous starter, and must be left intact.
for (const auto& entry : fs::directory_iterator(root)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
throw std::runtime_error("Create requires a new or empty project directory");
for (const auto& internal : fs::directory_iterator(entry.path())) {
if (internal.path().filename() == "starter-create.lock" &&
internal.is_directory() && !internal.is_symlink())
continue;
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
throw std::runtime_error("Create requires a new or empty project directory");
}
}
}
} // namespace
Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state},
{"stage", stage}, {"progress", progress}, {"log", log},
{"error", error}, {"result", result}};
{"error", error}, {"diagnostics", diagnostics}, {"result", result}};
}
void write_cooked_scene(const fs::path& path, const Json& scene) {
validate_scene(scene);
@@ -198,6 +298,13 @@ struct BuildService::Impl {
std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
if (job.cancelled)
throw Cancelled{};
std::string phase;
std::size_t diagnostics_before;
{
std::lock_guard lock(job.mutex);
phase = job.status.stage;
diagnostics_before = job.status.diagnostics.size();
}
std::string description = "$";
for (const auto& argument : arguments)
description += " " + Json(argument).dump();
@@ -205,6 +312,35 @@ struct BuildService::Impl {
log(job, description);
Process process({std::move(arguments), cwd, {}});
std::string output;
std::string pending;
const auto collect_diagnostics = [&](std::string_view chunk, bool final) {
pending.append(chunk);
std::size_t newline;
while ((newline = pending.find('\n')) != std::string::npos) {
const auto line = pending.substr(0, newline + 1);
pending.erase(0, newline + 1);
const auto found = parse_build_diagnostics(line, phase, config.project_root);
if (found.empty())
continue;
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
}
if (final && !pending.empty()) {
const auto found = parse_build_diagnostics(pending, phase, config.project_root);
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
pending.clear();
} else if (pending.size() > 8192)
pending.erase(0, pending.size() - 8192);
};
while (true) {
if (job.cancelled) {
process.cancel();
@@ -214,14 +350,29 @@ struct BuildService::Impl {
}
auto poll = process.poll();
log(job, poll.output);
collect_diagnostics(poll.output, !poll.running);
output += poll.output;
if (output.size() > max_log_bytes)
output.erase(0, output.size() - max_log_bytes);
if (!poll.running) {
if (poll.exit_code.value_or(1) != 0)
if (poll.exit_code.value_or(1) != 0) {
std::lock_guard lock(job.mutex);
bool has_error = false;
for (std::size_t index = diagnostics_before;
index < job.status.diagnostics.size(); ++index)
if (job.status.diagnostics[index].value("severity", "") == "error")
has_error = true;
if (!has_error && job.status.diagnostics.size() < 200)
job.status.diagnostics.push_back(
{{"severity", "error"},
{"phase", phase},
{"message", "Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log"}});
throw std::runtime_error("Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log");
}
return output;
}
std::this_thread::sleep_for(std::chrono::milliseconds(15));
@@ -240,19 +391,23 @@ struct BuildService::Impl {
}
}
Json build(Job& job, bool exporting = false) {
const auto started = std::chrono::steady_clock::now();
const auto milliseconds = [](auto from, auto to) {
return std::chrono::duration_cast<std::chrono::milliseconds>(to - from).count();
};
const auto& configuration = exporting ? config.export_configuration : config.configuration;
const auto native_directory = config.build_directory / configuration;
checkpoint(job, "Configuring gameplay", .05);
job.lua = scripting::loadLuaProject(config.project_root);
const auto cpp = config.project_root / "Scripts" / "Gameplay.cpp";
const auto hpp = config.project_root / "Scripts" / "Gameplay.hpp";
const auto inputs = capture_build_inputs(config, job.lua);
const auto staged_scripts = stage_gameplay_sources(config, inputs, job.lua, job.status.id);
const auto cpp = staged_scripts / "Gameplay.cpp";
const auto hpp = staged_scripts / "Gameplay.hpp";
const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp);
if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled()))
throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, "
"or Lua entry scripts declared in project.faset.json");
const auto cpp_source = has_cpp ? read_text(cpp) : std::string{};
const auto hpp_source = has_hpp ? read_text(hpp) : std::string{};
fs::create_directories(native_directory);
ensure_native_toolchain_stamp(config, native_directory, inputs);
std::vector<std::string> arguments = {config.cmake,
"-S",
path_to_utf8(config.engine_root),
@@ -267,37 +422,100 @@ struct BuildService::Impl {
"-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
path_to_utf8(config.project_root / "Scripts")};
bool compiler_overridden = false;
for (const auto& arg : config.configure_arguments)
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
compiler_overridden = true;
if (!compiler_overridden) {
path_to_utf8(staged_scripts)};
if (!configured_cmake_value(config, "CMAKE_C_COMPILER")) {
#ifdef _WIN32
auto compiler = find_executable("clang-cl");
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler));
#else
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang")));
#endif
}
if (!configured_cmake_value(config, "CMAKE_CXX_COMPILER")) {
#ifdef _WIN32
arguments.push_back("-DCMAKE_CXX_COMPILER=" +
path_to_utf8(find_executable("clang-cl")));
#else
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
#endif
}
arguments.insert(arguments.end(), config.configure_arguments.begin(),
config.configure_arguments.end());
for (const auto& argument : config.configure_arguments) {
const auto equal = argument.find('=');
const auto colon = argument.find(':', 2);
const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2);
if (key == "CMAKE_C_COMPILER" || key == "CMAKE_CXX_COMPILER" ||
key == "SLANGC_EXECUTABLE" || key == "CMAKE_TOOLCHAIN_FILE") {
const auto value = path_from_utf8(argument.substr(equal + 1));
if (value.has_parent_path() && value.is_relative()) {
arguments.push_back(argument.substr(0, equal + 1) +
path_to_utf8((config.project_root / value).lexically_normal()));
continue;
}
}
arguments.push_back(argument);
}
arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
// Project declarations, not a stale cache or a user-supplied override, determine
// whether the packaged game has a Lua VM linked into it.
arguments.push_back(std::string("-DFASET_ENABLE_LUA=") +
(job.lua.enabled() ? "ON" : "OFF"));
run(job, std::move(arguments), config.project_root);
const auto configured = std::chrono::steady_clock::now();
checkpoint(job, "Compiling and linking Player", .25);
run(job,
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root);
checkpoint(job, "Exporting gameplay schema", .58);
const auto compiled = std::chrono::steady_clock::now();
auto player = build_executable(native_directory, configuration, "faset_player");
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter");
const auto package_key =
build_package_key(inputs, native_directory, configuration, player, exporter);
const auto source_unchanged = [&] {
if (gameplay_source_hash(config.project_root,
scripting::loadLuaProject(config.project_root)) !=
inputs.source_hash)
throw std::runtime_error("Gameplay sources changed during the build; build again");
};
const auto result_for = [&](const std::string& id, const std::string& fingerprint,
bool reused) -> Json {
const auto directory = config.cache_root / "builds" / id;
const auto finished = std::chrono::steady_clock::now();
return {{"generation", id},
{"directory", path_to_utf8(directory)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", path_to_utf8(directory / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(directory / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint},
{"schema_cache_hit", reused},
{"generation_reused", reused},
{"phase_times_ms",
{{"configure", milliseconds(started, configured)},
{"native_build", milliseconds(configured, compiled)},
{"schema_package", reused ? 0 : milliseconds(compiled, finished)},
{"total", milliseconds(started, finished)}}}};
};
const auto pointer_file = config.cache_root / "last_build.json";
if (fs::is_regular_file(pointer_file))
try {
const auto pointer = read_json(pointer_file);
const auto id = pointer.at("generation").get<std::string>();
const auto candidate =
project_path(config.cache_root, fs::path("builds") / id);
if (validate_build_generation(candidate, package_key)) {
source_unchanged();
checkpoint(job, "Reusing verified build generation", .68);
const auto manifest = read_json(candidate / "manifest.json");
log(job, "Verified schema/package cache hit: " + id + "\n");
return result_for(id, manifest.at("fingerprint").get<std::string>(), true);
}
} catch (const std::exception&) {
// A bad pointer or old/corrupt generation is a cache miss.
}
checkpoint(job, "Exporting gameplay schema", .58);
const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id);
const auto generation = config.cache_root / "builds" / job.status.id;
fs::create_directories(staging);
@@ -322,10 +540,7 @@ struct BuildService::Impl {
// Validate the complete metadata before publishing either the schema or
// its Player generation. Session uses this same authoring contract.
(void)authoring::gameplay_schemas(schema);
std::string fingerprint = sha256_file(player) + sha256_file(exporter) +
read_text(native_directory / "CMakeCache.txt");
fingerprint += cpp_source + hpp_source + job.lua.fingerprint;
fingerprint = sha256(fingerprint);
const auto fingerprint = package_key;
schema["build_fingerprint"] = fingerprint;
if (job.lua.enabled())
schema["lua_fingerprint"] = job.lua.fingerprint;
@@ -341,36 +556,39 @@ struct BuildService::Impl {
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
if (entry.is_symlink())
throw std::runtime_error("Build generation contains a symlink");
if (entry.is_regular_file())
files.push_back(
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
{"sha256", sha256_file(entry.path())},
{"size", entry.file_size()}});
}
Json manifest{{"format", "faset.build"},
{"version", 1},
{"version", 2},
{"id", job.status.id},
{"fingerprint", fingerprint},
{"package_key", package_key},
{"configuration", configuration},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"}};
{"schema", "schema.json"},
{"files", files}};
atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68);
if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot changed during schema export");
if (scripting::loadLuaProject(config.project_root).fingerprint != job.lua.fingerprint ||
has_cpp != fs::is_regular_file(cpp) || has_hpp != fs::is_regular_file(hpp) ||
(has_cpp && (read_text(cpp) != cpp_source || read_text(hpp) != hpp_source)))
throw std::runtime_error("Gameplay sources changed during the build; build again");
source_unchanged();
if (!validate_build_generation(staging, package_key))
throw std::runtime_error("Build generation failed final integrity validation");
fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
return {{"generation", job.status.id},
{"directory", path_to_utf8(generation)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(generation / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint}};
return result_for(job.status.id, fingerprint, false);
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
@@ -785,6 +1003,97 @@ void BuildService::scaffold(const std::string& name, int dimension) {
if (!fs::exists(c.project_root / ".gitignore"))
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n");
}
void BuildService::scaffold(const std::string& name, int dimension,
std::string_view language) {
if (name.empty() || (dimension != 2 && dimension != 3) ||
(language != "cpp" && language != "lua"))
throw std::invalid_argument("Starter requires a name, 2D/3D and cpp/lua language");
const auto& c = impl_->config;
StarterCreateLock lock(c.project_root);
require_new_starter_directory(c.project_root);
const auto scene = starter_scene(name, dimension, language);
const auto source = c.engine_root / "tools/project_templates" /
(language == "lua" ? "lua-main.lua" : "Gameplay.cpp");
const auto module = read_text(source);
const auto header = language == "cpp"
? read_text(c.engine_root / "tools/project_templates/Gameplay.hpp")
: std::string();
const auto lua_annotations = language == "lua"
? read_text(c.engine_root / "tools/lua/faset.lua")
: std::string();
const auto lua_config = language == "lua"
? read_json(c.engine_root / "tools/lua/luarc.json")
: Json();
const auto lua_scripts_config =
language == "lua" ? read_json(c.engine_root / "tools/lua/luarc-scripts.json") : Json();
const auto stage = lock.path / "staged";
fs::create_directories(stage / "Scripts");
fs::create_directories(stage / "Scenes");
fs::create_directories(stage / "Assets");
if (language == "cpp") {
atomic_write(stage / "Scripts/Gameplay.cpp", module);
atomic_write(stage / "Scripts/Gameplay.hpp", header);
} else {
atomic_write(stage / "Scripts/main.lua", module);
atomic_write(stage / ".faset/lua/faset.lua", lua_annotations);
atomic_write_json(stage / ".luarc.json", lua_config);
atomic_write_json(stage / "Scripts/.luarc.json", lua_scripts_config);
}
atomic_write_json(stage / "Scenes/main.scene.json", scene);
Json project = {{"format", "faset.project"},
{"version", 1},
{"id", new_id()},
{"name", name},
{"dimension", dimension},
{"start_scene", "Scenes/main.scene.json"}};
if (language == "lua")
project["scripting"] = {{"lua", {{"scripts", Json::array({"Scripts/main.lua"})}}}};
atomic_write_json(stage / "project.faset.json", project);
atomic_write(stage / ".gitignore",
".faset/*\n!.faset/lua/\n.faset/lua/*\n!.faset/lua/faset.lua\nExports/\n");
std::vector<std::pair<fs::path, std::string>> created;
auto publish = [&](const fs::path& relative) {
const auto source = stage / relative;
const auto target = c.project_root / relative;
fs::create_directories(target.parent_path());
if (!fs::copy_file(source, target, fs::copy_options::none))
throw std::runtime_error("Starter destination appeared during creation: " +
path_to_utf8(target));
created.emplace_back(target, sha256_file(source));
};
try {
publish(".gitignore");
if (language == "cpp") {
publish("Scripts/Gameplay.cpp");
publish("Scripts/Gameplay.hpp");
} else {
publish("Scripts/main.lua");
publish(".faset/lua/faset.lua");
publish(".luarc.json");
publish("Scripts/.luarc.json");
}
publish("Scenes/main.scene.json");
fs::create_directory(c.project_root / "Assets");
// The manifest is the final commit marker: an interrupted create has no
// valid project record and never replaces an existing project file.
publish("project.faset.json");
} catch (...) {
for (auto it = created.rbegin(); it != created.rend(); ++it) {
try {
std::error_code ignored;
if (fs::is_regular_file(it->first, ignored) &&
sha256_file(it->first) == it->second)
fs::remove(it->first, ignored);
} catch (...) { /* Preserve the original create failure. */
}
}
for (const auto& relative : {"Assets", "Scenes", "Scripts", ".faset/lua"}) {
std::error_code ignored;
fs::remove(c.project_root / relative, ignored); // Empty directories only.
}
throw;
}
}
std::string BuildService::start_build() {
return impl_->enqueue("build");
}
+100 -57
View File
@@ -2,6 +2,7 @@
#include <chrono>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/session.hpp>
#include <faset/scripting/project.hpp>
@@ -112,6 +113,10 @@ void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension);
log("Created project: " + name);
}
void Session::scaffold(const std::string& name, int dimension, std::string_view language) {
builds_.scaffold(name, dimension, language);
log("Created " + std::string(language) + " starter project: " + name);
}
Json Session::assets_list() const {
Json list = Json::array();
const auto directory = assets_.cache_root() / "assets";
@@ -132,20 +137,7 @@ Json Session::assets_list() const {
}
std::string Session::source_signature() const {
const auto lua = scripting::loadLuaProject(config_.project_root);
const auto directory = config_.project_root / "Scripts";
std::vector<std::filesystem::path> files;
if (std::filesystem::exists(directory))
for (const auto& file : std::filesystem::recursive_directory_iterator(directory))
if (file.is_regular_file() && (!lua.enabled() || file.path().extension() != ".lua"))
files.push_back(file.path());
std::sort(files.begin(), files.end());
std::string contents;
for (const auto& file : files)
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
sha256_file(file) + "\n";
if (lua.enabled())
contents += "lua:" + lua.fingerprint + "\n";
return sha256(contents);
return gameplay_source_hash(config_.project_root, lua);
}
Json Session::schema_status() const {
try {
@@ -514,51 +506,102 @@ void Session::register_commands() {
{"configuration_created", create_configuration},
{"scripts_configuration_created", create_scripts_configuration}};
});
commands_.add(
"faset_script_open",
"Open a project Lua source in an external editor, never as an executable. Optional editor "
"is an argv array (default: project editor.script_editor, then zed). Exact {file} and "
"{project} arguments are replaced; no shell expansion is performed.",
schema({{"path", text}, {"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}},
{"path"}),
[&](const Json& args) {
const auto file = project_path(config_.project_root,
path_from_utf8(args.at("path").get<std::string>()));
require(file.extension() == ".lua" && std::filesystem::is_regular_file(file),
"lua.source", "Select an existing .lua file inside the project");
Json command = Json::array({"zed", "{file}"});
const auto settings = project();
if (settings.contains("editor") && settings.at("editor").is_object() &&
settings.at("editor").contains("script_editor"))
command = settings.at("editor").at("script_editor");
if (args.contains("editor"))
command = args.at("editor");
require(command.is_array() && !command.empty(), "lua.editor",
"Configure editor.script_editor as a nonempty executable/argument array");
std::vector<std::string> arguments;
bool has_file = false;
for (const auto& part : command) {
require(part.is_string(), "lua.editor", "Editor arguments must be strings");
auto argument = part.get<std::string>();
require(argument.find('\0') == std::string::npos, "lua.editor",
"Editor arguments cannot contain NUL");
if (argument == "{file}") {
require(!arguments.empty(), "lua.editor", "The first argument is the editor");
argument = path_to_utf8(file);
has_file = true;
} else if (argument == "{project}") {
require(!arguments.empty(), "lua.editor", "The first argument is the editor");
argument = path_to_utf8(std::filesystem::absolute(config_.project_root));
}
arguments.push_back(std::move(argument));
const auto open_source = [this](const Json& args, bool lua_only) -> Json {
const auto path_error = lua_only ? "lua.source" : "source.path";
const auto editor_error = lua_only ? "lua.editor" : "source.editor";
const auto requested = path_from_utf8(args.at("path").get<std::string>());
require(!requested.empty() && !requested.is_absolute(), path_error,
"Choose an existing source inside Scripts");
for (const auto& part : requested)
require(part != "..", path_error, "Source path cannot traverse outside Scripts");
std::filesystem::path file;
try {
file = project_path(config_.project_root, requested);
} catch (const std::exception&) {
throw Error(path_error, "Source path must stay inside project Scripts");
}
const auto project_root = std::filesystem::weakly_canonical(config_.project_root);
const auto relative = file.lexically_relative(project_root);
const auto name = generic_path_to_utf8(relative);
const auto extension = path_to_utf8(file.extension());
const bool supported = extension == ".cpp" || extension == ".hpp" ||
extension == ".h" || extension == ".cc" ||
extension == ".cxx" || extension == ".lua";
require(name.starts_with("Scripts/") && supported &&
(!lua_only || extension == ".lua") &&
std::filesystem::is_regular_file(file),
path_error, "Choose an existing source file inside Scripts");
const auto line = args.value("line", 1);
const auto column = args.value("column", 1);
require(line > 0 && column > 0, path_error, "Source line and column must be positive");
Json command = Json::array({"zed", "{file}:{line}:{column}"});
const auto settings = project();
if (settings.contains("editor") && settings.at("editor").is_object() &&
settings.at("editor").contains("script_editor"))
command = settings.at("editor").at("script_editor");
if (args.contains("editor"))
command = args.at("editor");
require(command.is_array() && !command.empty(), editor_error,
"Configure editor.script_editor as a nonempty executable/argument array");
auto replace = [](std::string& target, std::string_view token,
const std::string& replacement) {
bool found = false;
std::size_t position = 0;
while ((position = target.find(token, position)) != std::string::npos) {
target.replace(position, token.size(), replacement);
position += replacement.size();
found = true;
}
require(!arguments.front().empty(), "lua.editor", "Choose an editor executable");
if (!has_file)
arguments.push_back(path_to_utf8(file));
return found;
};
std::vector<std::string> arguments;
bool has_file = false;
for (const auto& part : command) {
require(part.is_string(), editor_error, "Editor arguments must be strings");
auto argument = part.get<std::string>();
require(argument.find('\0') == std::string::npos, editor_error,
"Editor arguments cannot contain NUL");
if (arguments.empty())
require(!argument.empty() && argument.find('{') == std::string::npos,
editor_error, "The first argument must be an editor executable");
else {
has_file |= replace(argument, "{file}", path_to_utf8(file));
replace(argument, "{line}", std::to_string(line));
replace(argument, "{column}", std::to_string(column));
replace(argument, "{project}", path_to_utf8(project_root));
}
arguments.push_back(std::move(argument));
}
if (!has_file)
arguments.push_back(path_to_utf8(file));
try {
launch_detached(arguments, config_.project_root);
log("Opened Lua source in external editor: " + args.at("path").get<std::string>());
return Json{{"opened", args.at("path")}};
});
} catch (const std::exception& error) {
throw Error(editor_error, "Could not launch external source editor",
{{"reason", error.what()}});
}
log("Opened source in external editor: " + name);
return {{"opened", name}, {"path", name}, {"line", line}, {"column", column}};
};
const auto source_schema = schema(
{{"path", text},
{"line", {{"type", "integer"}, {"minimum", 1}}},
{"column", {{"type", "integer"}, {"minimum", 1}}},
{"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}},
{"path"});
commands_.add("faset_source_open",
"Open a C++ or Lua source under Scripts at a one-based line and column. "
"The editor is an argv array; {file}, {line}, {column} and {project} are "
"substituted without a shell.",
source_schema, [open_source](const Json& args) {
return open_source(args, false);
});
commands_.add("faset_script_open",
"Open a project Lua source in an external editor. This compatibility command "
"retains Lua-only validation and uses the same safe source launcher.",
source_schema, [open_source](const Json& args) {
return open_source(args, true);
});
commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.",
+196
View File
@@ -0,0 +1,196 @@
#include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <iostream>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool condition, std::string_view message) {
if (!condition)
throw std::runtime_error(std::string(message));
}
void run(const fs::path& root) {
const auto project = root / "project";
const auto scripts = project / "Scripts";
atomic_write(scripts / "Gameplay.cpp", "#include \"Gameplay.hpp\"\n");
atomic_write(scripts / "Gameplay.hpp", "#pragma once\n");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 1\n");
const auto compiler = root / "fake-compiler";
const auto cmake = root / "fake-cmake";
const auto slang = root / "fake-slangc";
atomic_write(compiler, "compiler-v1\n");
atomic_write(cmake, "cmake-v1\n");
atomic_write(slang, "slang-v1\n");
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
config.build_directory = project / ".faset/build";
config.cmake = path_to_utf8(cmake);
config.configure_arguments = {
"-DCMAKE_C_COMPILER=" + path_to_utf8(compiler),
"-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler),
"-DSLANGC_EXECUTABLE=" + path_to_utf8(slang)};
const scripting::LuaProject none;
const auto initial = editor::capture_build_inputs(config, none);
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
const auto changed_header = editor::capture_build_inputs(config, none);
check(initial.source_hash != changed_header.source_hash &&
initial.fingerprint() != changed_header.fingerprint(),
"Nested gameplay header changes the complete source snapshot");
atomic_write(scripts / "main.lua", "return {}\n");
atomic_write_json(project / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"scripting", {{"lua", {{"scripts", {"Scripts/main.lua"}}}}}}});
auto lua = scripting::loadLuaProject(project);
const auto with_lua = editor::capture_build_inputs(config, lua);
atomic_write(scripts / "other.lua", "return {}\n");
auto manifest = read_json(project / "project.faset.json");
manifest["scripting"]["lua"]["scripts"].push_back("Scripts/other.lua");
atomic_write_json(project / "project.faset.json", manifest);
lua = scripting::loadLuaProject(project);
const auto changed_declaration = editor::capture_build_inputs(config, lua);
check(with_lua.source_hash != changed_declaration.source_hash,
"Lua entry declaration changes the source snapshot");
const auto native = config.build_directory / "Debug";
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
atomic_write(native / "sentinel", "keep\n");
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
check(fs::exists(native / "sentinel"), "Unchanged toolchain preserves the native tree");
atomic_write(compiler, "compiler-v2\n");
const auto changed_tool = editor::capture_build_inputs(config, lua);
check(changed_tool.source_hash == changed_declaration.source_hash &&
changed_tool.toolchain_hash != changed_declaration.toolchain_hash,
"Changing compiler bytes at the same path changes toolchain identity");
editor::ensure_native_toolchain_stamp(config, native, changed_tool);
check(!fs::exists(native / "sentinel"),
"Changed toolchain invalidates only the generated native tree");
const auto user_directory = root / "important-user-data";
atomic_write(user_directory / "do-not-delete.txt", "valuable\n");
bool refused_unowned{};
try {
editor::ensure_native_toolchain_stamp(config, user_directory, changed_tool);
} catch (const std::exception&) {
refused_unowned = true;
}
check(refused_unowned && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Unowned native directory is never cleared");
std::error_code native_link_error;
fs::create_directory_symlink(user_directory, config.build_directory / "Release",
native_link_error);
if (!native_link_error) {
bool refused_link{};
try {
editor::ensure_native_toolchain_stamp(config, config.build_directory / "Release",
changed_tool);
} catch (const std::exception&) {
refused_link = true;
}
check(refused_link && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Symlinked native directory cannot redirect cleanup into user data");
}
const auto relative_tool = project / "tools/cmake-fixture";
atomic_write(relative_tool, "relative-cmake-v1\n");
auto relative_config = config;
relative_config.cmake = "tools/cmake-fixture";
const auto relative_before = editor::capture_build_inputs(relative_config, lua);
atomic_write(relative_tool, "relative-cmake-v2\n");
check(relative_before.toolchain_hash !=
editor::capture_build_inputs(relative_config, lua).toolchain_hash,
"Relative CMake executable resolves from the project working directory");
auto typed_config = config;
const auto typed_compiler = root / "typed-compiler";
atomic_write(typed_compiler, "typed-v1\n");
typed_config.configure_arguments = {
"-DCMAKE_C_COMPILER:FILEPATH=" + path_to_utf8(typed_compiler)};
const auto typed_before = editor::capture_build_inputs(typed_config, lua);
atomic_write(typed_compiler, "typed-v2\n");
check(typed_before.toolchain_hash !=
editor::capture_build_inputs(typed_config, lua).toolchain_hash,
"Typed CMake compiler overrides are included in toolchain identity");
auto malicious_config = config;
malicious_config.configure_arguments.push_back(
"-DFASET_GAMEPLAY_SOURCE_DIR:PATH=" + path_to_utf8(root / "outside"));
bool refused_source_override{};
try {
(void)editor::capture_build_inputs(malicious_config, lua);
} catch (const std::exception&) {
refused_source_override = true;
}
check(refused_source_override,
"Configure arguments cannot redirect gameplay compilation away from hashed Scripts");
const auto staged = editor::stage_gameplay_sources(config, changed_tool, lua,
"snapshot-fixture");
check(staged != scripts && fs::is_regular_file(staged / "Gameplay.cpp") &&
read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"Native gameplay compilation uses a complete staged Scripts snapshot");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 999\n");
check(read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"A live edit cannot alter the source bytes of an in-flight native build");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
check(editor::stage_gameplay_sources(config, changed_tool, lua, "snapshot-again") ==
staged,
"Identical sources retain a stable native path for incremental Ninja builds");
atomic_write(native / "faset_player", "player-v1\n");
atomic_write(native / "faset_schema_exporter", "exporter-v1\n");
atomic_write(native / "CMakeCache.txt", "recipe-v1\n");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v1\n");
auto package_key = [&] (const editor::BuildInputs& inputs) {
return editor::build_package_key(inputs, native, "Debug", native / "faset_player",
native / "faset_schema_exporter");
};
const auto first_key = package_key(changed_tool);
check(first_key != package_key(changed_declaration) &&
first_key != package_key(with_lua),
"Toolchain and Lua source changes invalidate package identity");
auto option_config = config;
option_config.configure_arguments.push_back("-DFASET_TEST_OPTION=ON");
check(first_key != package_key(editor::capture_build_inputs(option_config, lua)),
"Configure option changes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v2\n");
check(first_key != package_key(changed_tool),
"Shader bytes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v2\n");
check(first_key != package_key(changed_tool),
"Runtime DLL bytes invalidate package identity");
fs::create_directories(root / "outside");
std::error_code link_error;
fs::create_directory_symlink(root / "outside", scripts / "linked", link_error);
if (!link_error) {
bool rejected = false;
try {
(void)editor::capture_build_inputs(config, lua);
} catch (const std::exception&) {
rejected = true;
}
check(rejected, "Source snapshot refuses a symlink escaping Scripts");
}
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / path_from_utf8("Faset build cache Café 世界 " + new_id());
try {
run(root);
fs::remove_all(root);
std::cout << "Source, toolchain and native package identity contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
fs::remove_all(root);
return 1;
}
}
+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"};
editor::BuildService builds(config);
builds.scaffold("Schema publication", 2);
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 1\n");
authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas());
const auto valid = manifest();
atomic_write_json(config.project_root / "schema-fixture.json", valid);
const auto first = builds.wait(builds.start_build());
{
auto compiler_config = config;
compiler_config.project_root = root / "c-only-compiler-project";
compiler_config.configure_arguments = {
"-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only"};
editor::BuildService compiler_build(compiler_config);
compiler_build.scaffold("Compiler argument fixture", 2);
atomic_write_json(compiler_config.project_root / "schema-fixture.json", valid);
const auto compiler_result = compiler_build.wait(compiler_build.start_build());
check(compiler_result.state == "succeeded",
"C-only compiler override fixture builds: " + compiler_result.error);
const auto compiler_args =
read_json(compiler_config.project_root / "configure-fixture.json");
std::size_t c_overrides{};
bool cxx_default{};
for (const auto& arg : compiler_args) {
const auto text = arg.get<std::string>();
if (text.starts_with("-DCMAKE_C_COMPILER")) {
++c_overrides;
check(text == "-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only",
"Typed C override is not replaced by a default compiler");
}
if (text.starts_with("-DCMAKE_CXX_COMPILER="))
cxx_default = true;
}
check(c_overrides == 1 && cxx_default,
"C and C++ compiler defaults are selected independently");
}
auto first = builds.wait(builds.start_build());
check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error);
fs::path compiled_scripts;
for (const auto& arg : read_json(config.project_root / "configure-fixture.json")) {
const auto text = arg.get<std::string>();
constexpr std::string_view prefix = "-DFASET_GAMEPLAY_SOURCE_DIR=";
if (text.starts_with(prefix))
compiled_scripts = path_from_utf8(text.substr(prefix.size()));
}
check(!compiled_scripts.empty() &&
compiled_scripts != config.project_root / "Scripts" &&
read_text(compiled_scripts / "Extensions/BuildOnly.hpp") ==
"#define BUILD_ONLY 1\n",
"Native C++ build receives the immutable source snapshot");
const auto repeated = builds.wait(builds.start_build());
check(repeated.state == "succeeded" &&
repeated.result.at("generation") == first.result.at("generation") &&
repeated.result.at("schema_cache_hit") == true &&
repeated.result.at("generation_reused") == true &&
read_text(config.project_root / "schema-export-count.txt") == "1",
"Unchanged native build reuses a verified schema generation");
atomic_write(path_from_utf8(first.result.at("schema").get<std::string>()), "truncated");
first = builds.wait(builds.start_build());
check(first.state == "succeeded" && first.result.at("schema_cache_hit") == false &&
read_text(config.project_root / "schema-export-count.txt") == "2",
"Corrupt schema cannot be a cache hit");
atomic_write(path_from_utf8(first.result.at("directory").get<std::string>()) /
"shaders/vertexMain.spv",
"corrupt");
first = builds.wait(builds.start_build());
check(first.state == "succeeded" && first.result.at("schema_cache_hit") == false &&
read_text(config.project_root / "schema-export-count.txt") == "3",
"Corrupt shader cannot be a cache hit");
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
@@ -161,6 +222,34 @@ int test_main(int argc, char** argv) {
const auto previous_player = sha256_file(player);
const auto previous_schema = read_text(schema);
const auto previous_manifest = read_text(directory / "manifest.json");
atomic_write(config.project_root / "emit-clang-error-and-fail-build", "fixture\n");
const auto compile_failed = builds.wait(builds.start_build());
check(compile_failed.state == "failed" &&
read_text(last_build) == previous_pointer &&
compile_failed.json().at("diagnostics").size() == 1 &&
compile_failed.json().at("diagnostics")[0].at("file") ==
"Scripts/Gameplay.cpp" &&
compile_failed.log.find("fixture compile failure") != std::string::npos,
"Failed compiler output retains raw log and structured source location");
fs::remove(config.project_root / "emit-clang-error-and-fail-build");
atomic_write(config.project_root / "fail-unparseable-build", "fixture\n");
const auto generic_failed = builds.wait(builds.start_build());
check(generic_failed.state == "failed" &&
generic_failed.json().at("diagnostics").size() == 1 &&
generic_failed.json().at("diagnostics")[0].at("severity") == "error" &&
!generic_failed.json().at("diagnostics")[0].contains("file") &&
read_text(last_build) == previous_pointer,
"Unparseable process failure has a non-navigable diagnostic");
fs::remove(config.project_root / "fail-unparseable-build");
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 3\n");
atomic_write(config.project_root / "mutate-cpp-header-during-build", "fixture\n");
const auto raced_header = builds.wait(builds.start_build());
check(raced_header.state == "failed" && read_text(last_build) == previous_pointer,
"Header changed during schema export cannot publish a mixed build");
fs::remove(config.project_root / "mutate-cpp-header-during-build");
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 1\n");
check(!first.result.at("lua_enabled").get<bool>() &&
!fs::exists(directory / "project.faset.json"),
"C++-only build publishes no Lua sources or project manifest");
@@ -169,6 +258,11 @@ int test_main(int argc, char** argv) {
"C++-only build explicitly disables the Lua VM in CMake");
authoring.replace_external_schemas(read_json(schema));
const auto previous_registry = authoring.schemas().manifest();
std::size_t published_generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
(void)entry;
++published_generations;
}
check(authoring.schemas().schema("game.mover").at("version") == 2,
"Matching custom v2 metadata reaches authoring");
migration_contracts(config.project_root / "migration-contracts", read_json(schema));
@@ -252,9 +346,11 @@ int test_main(int argc, char** argv) {
std::size_t generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
++generations;
check(entry.path() == directory, "Invalid build leaves no staging or generation");
check(!entry.path().filename().string().starts_with(".staging-"),
"Invalid build leaves no staging generation");
}
check(generations == 1, "Only the validated build generation remains");
check(generations == published_generations,
"Invalid build publishes no new generation");
bool rejected{};
try {
authoring.replace_external_schemas(invalid[index]);
@@ -305,6 +401,8 @@ int test_main(int argc, char** argv) {
"Later edits never mutate an already published Lua generation");
const auto lua_pointer = read_text(last_build);
for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) {
atomic_write(config.project_root / "Scripts/main.lua",
std::string(lua_source) + "-- force a schema export\n");
atomic_write(config.project_root / marker, "fixture\n");
const auto raced = builds.wait(builds.start_build());
check(raced.state == "failed" && read_text(last_build) == lua_pointer,
+16 -1
View File
@@ -8,6 +8,9 @@ using namespace faset;
int tool_main(int argc, char** argv) {
try {
if (argc >= 3 && std::string_view(argv[1]) == "--output") {
const auto count_file = fs::path("schema-export-count.txt");
const auto count = fs::exists(count_file) ? std::stoi(read_text(count_file)) : 0;
atomic_write(count_file, std::to_string(count + 1));
if (argc == 5 && std::string_view(argv[3]) == "--project") {
const auto snapshot = path_from_utf8(argv[4]);
const auto project = read_json(snapshot / "project.faset.json");
@@ -21,6 +24,8 @@ int tool_main(int argc, char** argv) {
if (fs::exists("mutate-lua-snapshot"))
atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n");
}
if (fs::exists("mutate-cpp-header-during-build"))
atomic_write("Scripts/Extensions/BuildOnly.hpp", "#define BUILD_ONLY 2\n");
atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
return 0;
}
@@ -34,8 +39,18 @@ int tool_main(int argc, char** argv) {
std::cout << "Native packaging fixture validated\n";
return 0;
}
if (argc > 2 && std::string_view(argv[1]) == "--build")
if (argc > 2 && std::string_view(argv[1]) == "--build") {
if (fs::exists("emit-clang-error-and-fail-build")) {
std::cerr << path_to_utf8(fs::current_path() / "Scripts/Gameplay.cpp")
<< ":7:3: error: fixture compile failure\n";
return 1;
}
if (fs::exists("fail-unparseable-build")) {
std::cerr << "Synthetic native build failed without a source location\n";
return 1;
}
return 0;
}
fs::path build;
for (int i = 1; i + 1 < argc; ++i)
if (std::string_view(argv[i]) == "-B")
+101
View File
@@ -1,8 +1,12 @@
#include "assets_image_fixtures.hpp"
#include <algorithm>
#include <atomic>
#include <bit>
#include <chrono>
#include <cstdlib>
#include <functional>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
@@ -42,6 +46,102 @@ Json scene(int dimension) {
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}};
}
void starter_contracts(const fs::path& root) {
for (const int dimension : {2, 3}) {
for (const char* language : {"cpp", "lua"}) {
const auto project = root / path_from_utf8(std::string("Starter café ") + language +
std::to_string(dimension));
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService service(config);
service.scaffold("Starter", dimension, language);
const auto manifest = read_json(project / "project.faset.json");
const auto start = read_json(project / "Scenes/main.scene.json");
require(manifest.at("dimension") == dimension &&
manifest.at("start_scene") == "Scenes/main.scene.json",
"Explicit starter has the requested scene type and startup path");
require(start.at("dimension") == dimension && !start.at("entities").empty(),
"Starter scene is valid and has visible contents");
auto builtins = start;
bool has_behavior = false;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [&](const Json& component) {
const auto custom =
!component.at("type").get<std::string>().starts_with("faset.");
has_behavior |= custom;
return custom;
});
}
require(has_behavior, "Starter scene binds its gameplay behavior");
authoring::validate_scene(builtins, authoring::builtin_schemas());
if (std::string_view(language) == "lua") {
require(!fs::exists(project / "Scripts/Gameplay.cpp") &&
!fs::exists(project / "Scripts/Gameplay.hpp"),
"Lua starter has no C++ gameplay stub");
require(manifest.at("scripting").at("lua").at("scripts") ==
Json::array({"Scripts/main.lua"}) &&
scripting::loadLuaProject(project).enabled() &&
fs::is_regular_file(project / ".luarc.json") &&
fs::is_regular_file(project / "Scripts/.luarc.json") &&
fs::is_regular_file(project / ".faset/lua/faset.lua"),
"Lua starter declares an actual runnable module");
} else {
require(fs::is_regular_file(project / "Scripts/Gameplay.cpp") &&
fs::is_regular_file(project / "Scripts/Gameplay.hpp") &&
!manifest.contains("scripting"),
"C++ starter contains its compiled gameplay module");
}
const auto marker = read_text(project / "Scenes/main.scene.json");
bool rejected = false;
try {
service.scaffold("Again", dimension, language);
} catch (const std::exception&) {
rejected = true;
}
require(rejected && read_text(project / "Scenes/main.scene.json") == marker,
"Explicit creation rejects existing project without overwriting it");
}
}
editor::BuildConfig legacy;
legacy.project_root = root / "legacy";
legacy.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService(legacy).scaffold("Legacy", 3);
require(!fs::exists(legacy.project_root / "Scenes/main.scene.json"),
"Two-argument scaffold retains its prior no-scene behavior");
const auto concurrent_root = root / "concurrent";
editor::BuildConfig concurrent;
concurrent.project_root = concurrent_root;
concurrent.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService first(concurrent), second(concurrent);
std::atomic<int> ready = 0;
std::atomic<int> successes = 0;
auto attempt = [&](editor::BuildService& service, const char* name) {
++ready;
while (ready.load() < 2)
std::this_thread::yield();
try {
service.scaffold(name, 2, "lua");
++successes;
} catch (const std::exception&) {
}
};
std::thread a(attempt, std::ref(first), "First");
std::thread b(attempt, std::ref(second), "Second");
a.join();
b.join();
require(successes == 1, "Two concurrent creators publish exactly one starter");
const auto winner = read_json(concurrent_root / "project.faset.json");
require(winner.at("name") == "First" || winner.at("name") == "Second",
"Published manifest belongs to the successful creator");
require(fs::is_regular_file(concurrent_root / "Scripts/main.lua") &&
fs::is_regular_file(concurrent_root / "Scenes/main.scene.json"),
"The winning starter publishes all required files");
const auto ignore = read_text(concurrent_root / ".gitignore");
require(ignore.find("!.faset/lua/faset.lua") != std::string::npos,
"LuaLS declarations are commit-friendly in generated projects");
}
void lua_project_contracts(const fs::path& root) {
fs::create_directories(root);
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
@@ -379,6 +479,7 @@ int test_main(int argc, char** argv) {
try {
fs::create_directories(temporary);
lua_project_contracts(temporary / "lua-project");
starter_contracts(temporary / "starters");
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
const auto executable = temporary / original_executable.filename();
fs::copy_file(original_executable, executable);
+36 -1
View File
@@ -1,9 +1,18 @@
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
#include <chrono>
#include <iostream>
#include <thread>
int main() {
int main(int argc, char** argv) {
using namespace faset;
if (argc >= 3 && std::string_view(argv[1]) == "--editor-probe") {
Json arguments = Json::array();
for (int index = 3; index < argc; ++index)
arguments.push_back(argv[index]);
atomic_write_json(path_from_utf8(argv[2]), arguments);
return 0;
}
const auto root = std::filesystem::temp_directory_path() /
path_from_utf8("Faset Café 世界 session " + new_id());
try {
@@ -115,6 +124,32 @@ int main() {
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
"lua.editor");
const auto probe = root / "editor-argv.json";
const auto opened = commands.call(
"faset_source_open",
{{"path", "Scripts/Gameplay.cpp"}, {"line", 17}, {"column", 4},
{"editor", Json::array({path_to_utf8(std::filesystem::absolute(path_from_utf8(argv[0]))),
"--editor-probe", path_to_utf8(probe),
"{file}:{line}:{column}", "{project}"})}});
for (int attempt = 0; attempt < 100 && !std::filesystem::exists(probe); ++attempt)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
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();
bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}};
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()));
click(launcher, "launcher-2d");
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
click(launcher, "launcher-lua");
check(launcher.widgets().find("launcher-lua")->selected,
"Lua project language selection");
renderer.render(launcher.snapshot());
renderer.capture(root / "launcher-create.ppm");
launcher.frame({key("Return", true)});
@@ -118,6 +121,7 @@ int main() {
"Create through keyboard rejected: " +
launcher.widgets().find("launcher-error")->text);
check(launcher.selection()->create && launcher.selection()->dimension == 2 &&
launcher.selection()->language == "lua" &&
launcher.selection()->name == "Тестовый проект",
"Create through keyboard preserves typed project metadata");
check(launcher.selection()->path == new_project,
@@ -127,6 +131,19 @@ int main() {
check(!std::filesystem::exists(new_project),
"Launcher does not create a partial project before Session scaffold");
}
{
editor::ProjectSelection retry{new_project, "Retry starter", 3, true, "lua"};
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
recents, retry, "Template source is unavailable");
launcher.frame({});
check(launcher.widgets().find("launcher-create")->selected &&
launcher.widgets().find("launcher-lua")->selected &&
launcher.widgets().find("launcher-path")->text == path_to_utf8(new_project) &&
launcher.widgets().find("launcher-name")->text == "Retry starter" &&
launcher.widgets().find("launcher-error")->text ==
"Template source is unavailable",
"Failed starter returns to Create with the chosen options and error");
}
{
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE),
root / "Missing", recents);
+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