Author SHA1 Message Date
Emil 5e26c2315e Record build profile and temporal raster cost in lighting benchmark
Native and manual checks / native (ubuntu-24.04) (push) Failing after 31s
Native and manual checks / manual (push) Successful in 28s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
2026-09-24 02:49:21 +03:00
Emil e074713fed Check real lighting benchmark output and driver identity 2026-09-24 02:43:18 +03:00
Emil d871db1ded Measure P3 lighting sweep and Forward+ threshold 2026-09-24 02:40:11 +03:00
Emil f490254ab3 Add repeatable P3 lighting renderer benchmark 2026-09-24 02:40:03 +03:00
Emil 36a44e14ca Plan bounded sun and local shadows from source casters 2026-09-24 02:26:53 +03:00
Emil cfcfdab949 Bind shared lighting ABI and shade authored local lights 2026-09-24 01:53:14 +03:00
Emil 674e3e812b Expose authored sun, point, and spot lights in render snapshots 2026-09-24 01:35:20 +03:00
81 changed files with 2532 additions and 6584 deletions
-1
View File
@@ -96,4 +96,3 @@ jobs:
python-version: '3.12'
- run: python -m pip install -r docs/requirements.txt
- run: python -m mkdocs build --strict
- run: python tests/verify_playable_exports_test.py
+2 -2
View File
@@ -72,8 +72,8 @@ jobs:
run: ctest --preset windows-debug --timeout 180
- name: Real Release exports, 2D and 3D execution, incremental Debug rebuild
run: build/windows-debug/faset_build_service_tests.exe --integration .cache/windows-export-e2e
- name: Export and relocate C++ and Lua playable games
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports --include-lua
- name: Export and relocate both checked-in playable games
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports
- name: Preserve graphics and export evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+1 -4
View File
@@ -74,16 +74,13 @@ if(TARGET faset_assets AND TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_D
endif()
if(TARGET faset_editor_commands AND TARGET faset_build_service)
include(cmake/Plugins.cmake)
add_library(faset_editor_session STATIC src/editor/session.cpp src/editor/autosave.cpp)
add_library(faset_editor_session STATIC src/editor/session.cpp)
target_link_libraries(faset_editor_session PUBLIC faset_editor_commands faset_build_service faset_assets faset_editor_plugins)
if(BUILD_TESTING)
add_executable(faset_editor_session_tests tests/editor_session_tests.cpp)
target_link_libraries(faset_editor_session_tests PRIVATE faset_editor_session)
target_compile_definitions(faset_editor_session_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
add_test(NAME editor_session_settings COMMAND faset_editor_session_tests)
add_executable(faset_editor_autosave_tests tests/editor_autosave_tests.cpp)
target_link_libraries(faset_editor_autosave_tests PRIVATE faset_editor_session)
add_test(NAME editor_autosave COMMAND faset_editor_autosave_tests)
endif()
endif()
foreach(module UI EditorUI Editor Applications)
+7 -50
View File
@@ -4,7 +4,6 @@
#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>
@@ -41,7 +40,7 @@ void help() {
std::cout
<< "Faset Editor\n"
" faset_editor Open the project launcher\n"
" faset_editor --project PATH [--new NAME --dimension 2|3 [--language cpp|lua]] [--scene RELATIVE_PATH]\n"
" faset_editor --project PATH [--new NAME --dimension 2|3] [--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"
@@ -53,10 +52,9 @@ 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, language = "cpp";
std::string new_name, command;
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];
@@ -76,10 +74,6 @@ 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")
@@ -106,65 +100,30 @@ 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,
retry_create, creation_error);
retry_create.reset();
creation_error.clear();
run_project_launcher(engine, previous_project, frames, capture);
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
}
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;
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
executable_directory(argv[0])});
if (!new_name.empty())
session.scaffold(new_name, dimension);
const auto settings = session.project();
#ifdef FASET_HAS_EDITOR_UI
if (gui && std::filesystem::exists(project / "project.faset.json"))
@@ -208,8 +167,6 @@ int editor_main(int argc, char** argv) {
project.clear();
scene.clear();
new_name.clear();
explicit_language = false;
language = "cpp";
continue;
#else
throw Error(
+10 -52
View File
@@ -75,22 +75,6 @@ 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);
@@ -140,11 +124,9 @@ 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 std::optional<ProjectSelection>& retry,
std::string creation_error)
const fs::path& recents)
: 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");
@@ -165,22 +147,13 @@ 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 = retry ? retry->path
: initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
ui.update_text("launcher-path", path_to_utf8(start));
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);
}
create = initial.empty();
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(); };
@@ -236,14 +209,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 (!fresh_destination(path))
if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(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, language};
selected = ProjectSelection{path, name, dimension, true};
} else
selected = open_project(path);
error.clear();
@@ -378,24 +351,14 @@ 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-language-group",
"launcher-language-space"})
"launcher-dimension-group"})
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";
@@ -461,11 +424,8 @@ struct ProjectLauncher::Impl {
}
};
ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine,
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))) {}
const fs::path& initial, const fs::path& recents)
: impl_(std::make_unique<Impl>(r, engine, initial, recents)) {}
ProjectLauncher::~ProjectLauncher() = default;
void ProjectLauncher::frame(const std::vector<render::Event>& events) {
impl_->frame(events);
@@ -485,11 +445,9 @@ 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 std::optional<ProjectSelection>& retry,
std::string creation_error) {
const fs::path& capture) {
render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true});
ProjectLauncher launcher(renderer, engine, initial, {}, retry, std::move(creation_error));
ProjectLauncher launcher(renderer, engine, initial);
std::uint64_t frames = 0;
while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() &&
(max_frames == 0 || frames < max_frames)) {
-9
View File
@@ -41,15 +41,6 @@
{"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}},
+2 -19
View File
@@ -1,18 +1,8 @@
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)
add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.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}")
@@ -23,12 +13,5 @@ if(BUILD_TESTING)
target_link_libraries(faset_build_schema_tests PRIVATE faset_build_service faset_authoring faset_editor_commands)
add_dependencies(faset_build_schema_tests faset_build_schema_tool)
add_test(NAME build_schema_publication COMMAND faset_build_schema_tests $<TARGET_FILE:faset_build_schema_tool> ${PROJECT_SOURCE_DIR})
# This fixture performs many full cache invalidations and process launches.
# Windows runner startup and antivirus overhead exceed 60 seconds even when
# individual native fixture commands finish normally.
if(WIN32)
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 240)
else()
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60)
endif()
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60)
endif()
-3
View File
@@ -47,8 +47,5 @@ if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view)
set_tests_properties(editor_ui_gizmos PROPERTIES LABELS "gpu")
add_test(NAME editor_ui_authoring COMMAND faset_editor_ui_tests)
set_tests_properties(editor_ui_authoring PROPERTIES LABELS "gpu")
add_executable(faset_editor_ui_latency ${PROJECT_SOURCE_DIR}/tests/editor_ui_latency.cpp)
target_link_libraries(faset_editor_ui_latency PRIVATE faset_editor_ui)
target_compile_definitions(faset_editor_ui_latency PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
endif()
endif()
+17 -1
View File
@@ -40,13 +40,17 @@ add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv"
-o "${FASET_SHADER_DIRECTORY}/compatibility.spv"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" VERBATIM)
add_custom_target(faset_shaders DEPENDS ${FASET_SHADER_OUTPUTS} "${FASET_SHADER_DIRECTORY}/compatibility.spv")
add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp")
add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp" "${PROJECT_SOURCE_DIR}/src/render/lighting.cpp")
target_include_directories(faset_render PUBLIC "${PROJECT_SOURCE_DIR}/include")
target_compile_features(faset_render PUBLIC cxx_std_20)
target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3 faset_core)
target_compile_definitions(faset_render PRIVATE FASET_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}")
add_dependencies(faset_render faset_shaders)
if(BUILD_TESTING)
add_executable(faset_render_lighting_policy_tests "${PROJECT_SOURCE_DIR}/tests/render_lighting_policy_tests.cpp")
target_link_libraries(faset_render_lighting_policy_tests PRIVATE faset_render)
add_test(NAME render_lighting_policy COMMAND faset_render_lighting_policy_tests)
set_tests_properties(render_lighting_policy PROPERTIES LABELS "p3")
add_executable(faset_render_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp")
target_link_libraries(faset_render_tests PRIVATE faset_render SDL3::SDL3)
add_test(NAME render_graph COMMAND faset_render_tests --unit)
@@ -78,5 +82,17 @@ if(BUILD_TESTING)
target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3)
add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test")
set_tests_properties(render_window_lifecycle PROPERTIES LABELS "gpu;window" TIMEOUT 40 SKIP_RETURN_CODE 77)
add_executable(faset_p3_lighting_benchmark
"${PROJECT_SOURCE_DIR}/examples/renderer/p3_lighting_benchmark.cpp")
target_link_libraries(faset_p3_lighting_benchmark PRIVATE faset_render faset_core)
target_compile_definitions(faset_p3_lighting_benchmark PRIVATE
FASET_BENCHMARK_CONFIGURATION="$<CONFIG>")
add_test(NAME render_lighting_benchmark_schema COMMAND
"${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py")
set_tests_properties(render_lighting_benchmark_schema PROPERTIES LABELS "p3" TIMEOUT 90)
add_test(NAME render_lighting_benchmark_smoke COMMAND
"${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py"
--real-executable "$<TARGET_FILE:faset_p3_lighting_benchmark>")
set_tests_properties(render_lighting_benchmark_smoke PROPERTIES LABELS "gpu;p3" TIMEOUT 90)
endif()
install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders)
-23
View File
@@ -1,28 +1,5 @@
# Developer diagnostics
## Build errors and source navigation
Build and export jobs report compiler, clang-cl/MSVC, and Lua errors as structured
Console rows. Each row shows severity, message, and a one-based source location.
Choose **Open source** to open a file under the current project's `Scripts/`
directory at that line and column. Diagnostics from engine or external files remain
visible but have no source action. The **Jobs** tab shows whether a verified build
generation was reused, the elapsed phases, and an expandable raw process log;
**Copy full log** retains the complete available output for troubleshooting.
The same action is available over Editor MCP:
```json
{"command":"faset_source_open","arguments":{"path":"Scripts/Gameplay.cpp","line":17,"column":4}}
```
`faset_script_open` remains Lua-only. Both commands use the project's optional
`editor.script_editor` argv array, or Zed by default. The tokens `{file}`,
`{line}`, `{column}`, and `{project}` may appear within one argument, for
example `["zed", "{file}:{line}:{column}"]`. Faset passes each argument
literally to the editor process without shell expansion. An invalid editor
command produces a source-editor error without changing the build result.
The optional Dear ImGui overlay shows renderer counters inside the Editor. The Editor's normal interface remains the retained Faset UI. Enable the diagnostic build explicitly:
```sh
+92
View File
@@ -0,0 +1,92 @@
# Add lights to a 3D scene
Add a **Light** component to a scene entity. The entity's transform places a point
or spot light; its rotation aims a spot light along local negative Z. A directional
light uses the entity's orientation. Light colors and intensity contribute to the
mesh's linear PBR illumination before tone mapping. Sprites and UI retain their
unlit tint.
The version-1 `faset.light` component has three `kind` values:
| Kind | Position and direction | Useful fields |
| --- | --- | --- |
| `directional` | Direction from the entity transform | `color`, `intensity`, `casts_shadow` |
| `point` | Position from the entity transform; illuminates every direction | `color`, `intensity`, `range` |
| `spot` | Position and local negative-Z direction | `color`, `intensity`, `range`, `inner_angle`, `outer_angle` |
Angles are radians. A spot's inner angle must not exceed its outer angle. Intensity
must be nonnegative and range positive. `enabled: false` keeps the component in the
scene without contributing light. The `shadow_priority` integer is reserved for the
bounded local-shadow scheduler; it does not change brightness.
In the current rendering checkpoint, one enabled directional light can cast the
existing single-map shadow. Point and spot lights illuminate meshes but do not yet
cast shadows. The [P3 lighting plan](https://github.com/emil28092005/Faset_Engine/blob/main/docs/superpowers/plans/2026-09-24-p3-lighting.md)
tracks cascades and the bounded local-shadow atlas. A scene with no Light component
keeps the legacy white sun so older projects retain their appearance. Adding any
Light component, even a disabled one, turns off that compatibility fallback. If
several directionals are enabled, Faset chooses the one with the smallest stable
entity ID and reports a diagnostic for the others.
## Author a point light through MCP
Use `faset_schema` to inspect the current field IDs, then send a `faset_scene_edit`
batch with the document ID, current revision, and target entity ID. For example:
```json
{
"document": "REPLACE_WITH_DOCUMENT_ID",
"revision": 4,
"idempotency_key": "add-red-point-light",
"operations": [{
"op": "component.add",
"entity": "REPLACE_WITH_ENTITY_ID",
"type": "faset.light",
"fields": {
"kind": "point",
"color": [1, 0.15, 0.1, 1],
"intensity": 8,
"range": 6
}
}]
}
```
Move the entity with its Transform component. `component.add` fills any omitted
light fields from the version-1 schema; use `component.set` for later edits. See
[MCP and command line](mcp.md) for revision and retry handling.
## Supply lights directly from C++
When building a `faset::render::Snapshot` yourself, set
`authored_lights_present` to suppress the compatibility sun in a local-only scene.
Provide a stable ID for each light so future shadow scheduling remains independent
of submission order.
```cpp
faset::render::Snapshot snapshot;
snapshot.authored_lights_present = true;
faset::render::LocalLight point;
point.kind = faset::render::LocalLight::Kind::Point;
point.stable_id = "level/torch";
point.position = {-2, 1.5f, 0};
point.color = {1, 0.3f, 0.1f, 1};
point.intensity = 8;
point.range = 6;
snapshot.local_lights.push_back(point);
faset::render::LocalLight spot;
spot.kind = faset::render::LocalLight::Kind::Spot;
spot.stable_id = "level/lamp";
spot.position = {2, 3, 0};
spot.direction = {0, -1, 0};
spot.inner_angle = 0.25f;
spot.outer_angle = 0.55f;
spot.intensity = 5;
spot.range = 9;
snapshot.local_lights.push_back(spot);
```
The renderer submits at most 128 local lights per frame in stable-ID order. Later
P3 work adds explicit overflow diagnostics and measured light-list optimization.
-14
View File
@@ -67,20 +67,6 @@ resume and single-step process controls; it cannot read or modify game entities.
scenes that have never been saved. Restoring an already open document requires its
current revision. Recovery refuses to overwrite an externally changed scene file.
Long-lived headless and graphical Editor sessions also poll the same autosave
controller. With `editor.autosave` enabled (the default), a named dirty scene is
saved after two seconds idle. `faset_autosave_status {}` reports `enabled` and each
open document's `state`, `revision`, `path`, and `error`. States include `saved`,
`pending`, `saving`, `conflict`, `failed`, `save_as_required`, and `disabled`.
An unnamed scene remains in recovery until you give it an explicit path. An
external disk edit produces `conflict` without overwriting that edit. For scripted
workflows, use `faset_document_save` with `expected_revision` when you need a
definite save boundary instead of waiting for idle autosave. If a conflict occurs,
save to a new project-relative path and compare the two files. Change the
preference through `faset_project_settings_get` and `faset_project_settings_set`
with the returned project revision and `{"editor":{"autosave":false}}`; the
new value applies to the current session and persists for the next Editor launch.
## Single-command CLI
The CLI is useful for scripts that do not need an MCP session:
+6 -22
View File
@@ -50,19 +50,6 @@ outside the Editor, ordinary Save reports a conflict instead of overwriting it.
Save your in-memory work to a new path and compare the two versions before replacing
anything.
Autosave is enabled by default. After a named scene has been idle for two seconds,
the Editor saves its current revision to that scene file. The status bar shows
**Pending autosave**, **Saving**, or **Saved**. An unnamed scene shows **Save As
required** until you choose its path. The status bar's **Save As...** action opens
an editable, new project-relative path; it never overwrites the conflicting file.
If the disk file changes outside Faset, autosave stops for that revision and shows
**Save conflict**. Use **Save As...** to keep both versions, then compare or reload
the original. **Save failed** preserves the unsaved scene in the recovery journal;
correct the write error or use Save As. Autosave does not clear Undo history or
replace the [recovery journal](#recover-unsaved-work). Recovery still protects
unnamed scenes and edits before the idle deadline.
## Navigate and transform
- **Right drag:** orbit the 3D view; pan in a 2D scene.
@@ -132,16 +119,13 @@ for the C++ iteration loop.
## Project settings
Open **File → Project settings** to change the project name, initial **2D / 3D** type,
start-scene path, or **Autosave named scenes** preference. Save a scene first, then
choose it from the saved-scene list or enter its project-relative path. Choose
**Save project** to update `project.faset.json` explicitly. A missing start scene is
rejected and the dialog stays open. The Autosave preference applies immediately;
when disabled, the status bar says **Autosave off** and you can still use Ctrl+S or
Save As. The project file stores it as `editor.autosave`; omitting it means enabled.
and start-scene path. Save a scene first, then choose it from the saved-scene list or
enter its project-relative path. Choose **Save project** to update
`project.faset.json` explicitly. A missing start scene is rejected and the dialog
stays open.
The project name, default dimension, and start scene are used when the project opens
again; they do not convert the active scene, switch its dimension, restart the Player,
or add a scene Undo entry.
These settings are used when the project opens again; they do not convert the active
scene, switch its dimension, restart the Player, or add a scene Undo entry.
**Cancel** or **Escape** discards this form's draft. If another writer changes the
project settings while the dialog is open, Save reports a revision conflict and
keeps your draft. **Reload saved** deliberately discards it and reloads the latest
+3 -13
View File
@@ -31,27 +31,17 @@ cmake --build --preset linux-debug --parallel
ctest --preset linux-debug
```
Create a runnable C++ or Lua starter and open the native Editor:
Create a project and open the native Editor:
```sh
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
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3
```
You can also run `build/linux-debug/faset_editor` without arguments to open the
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.
project launcher and create or select a project using the native interface.
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,23 +5,6 @@ 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`:
@@ -42,9 +42,9 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo
**Files:** Modify `include/faset/render/renderer.hpp`, `src/authoring/schema.cpp`, `src/player/SceneView.cpp`, `tests/authoring_tests.cpp`, `tests/runtime_player_tests.cpp`, and relevant Manual authoring examples.
**Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional<SunLight> Snapshot::sun`, `std::vector<LocalLight> Snapshot::local_lights`, and `std::optional<CameraFrustum> Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes and picks the first enabled directional by stable ID.
**Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional<SunLight> Snapshot::sun`, `std::vector<LocalLight> Snapshot::local_lights`, `bool Snapshot::authored_lights_present` (default false), and `std::optional<CameraFrustum> Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes, sets authored-light presence even for disabled/future-version light components, and picks the first enabled directional by stable ID. A legacy sun is synthesized only when both sun and authored-light presence are absent.
- [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; no-light scene retains the default legacy sun; extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is:
- [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; a no-light scene has `authored_lights_present == false` and retains the default legacy sun, while a local-only or explicitly disabled-sun scene has the flag true and no sun. Extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is:
```cpp
auto a = view.build(scene_with_three_lights(), 16.f / 9.f);
auto b = view.build(reordered_scene_with_three_lights(), 16.f / 9.f);
@@ -54,14 +54,14 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo
"Light ordering follows stable IDs, not entity array order");
```
- [ ] **Step 2: Run the focused tests red.** Run `cmake --preset linux-debug` and `cmake --build --preset linux-debug --target faset_authoring_tests faset_player_tests --parallel 4`; missing typed fields should fail compilation. If compilation succeeds, `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(authoring|player_scene_contracts)$'` must fail on a new behavioral assertion. Record the expected failure rather than assuming the build itself must be red.
- [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light.
- [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback only when no authored light component exists. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light.
```cpp
struct CameraFrustum {
Mat4 view{identity}, projection{identity};
float near_plane{0.1f}, far_plane{1000.f};
bool perspective{true};
};
// Snapshot::light_direction remains available to callers without Snapshot::sun.
// Snapshot::light_direction remains a fallback only if authored_lights_present is false.
```
- [ ] **Step 4: Run focused tests green.** `ctest --test-dir build/linux-debug --output-on-failure -R '^(authoring|player_scene_contracts)$'` passes, including old version-1 scenes and exported-scene decoding.
- [ ] **Step 5: Commit** `Expose authored sun, point, and spot lights in render snapshots`.
@@ -12,7 +12,7 @@ P3 supplies one sun plus point and spot lights, camera-fitted cascaded sun shado
Keep `faset.light` at builtin schema version 1 and add optional fields with defaults: `kind` (`directional`, `point`, `spot`, default `directional`), `enabled` (true), `color` (white), `intensity` (1, nonnegative), `range` (10, positive, for local lights), `inner_angle` (0.35 radians), `outer_angle` (0.70 radians, strictly below π/2), `casts_shadow` (true), and `shadow_priority` (integer 0). Existing fields and component IDs remain valid. Cross-field validation requires `0 <= inner_angle <= outer_angle`; local range, color channels, intensity, transforms, and cone directions must be finite. Invalid authored values return a diagnostic with entity and field rather than nonfinite GPU data. Missing new fields use the schema defaults.
`Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. If there is no authored directional light, the renderer synthesizes the legacy sun from `Snapshot::light_direction`. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output.
`Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, an `authored_lights_present` flag (default false), and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene and sets the flag if any authored light component exists, including a disabled or future-version component. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. The renderer synthesizes the legacy sun from `Snapshot::light_direction` only when `sun` is absent **and** `authored_lights_present` is false. Thus an old scene without light components keeps its previous appearance, while a local-only scene or explicitly disabled sun does not receive an unintended directional light. Low-level callers may set the flag to request a dark scene without authoring metadata. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output.
Explicit camera frustum data is required for four cascades. If a low-level caller supplies only the legacy `view_projection`, the renderer uses one bounded legacy-compatible sun shadow view and reports `effective_sun_cascades = 1`; it never silently claims CSM. 2D sprite-only snapshots do not incur shadow work. The future temporal stage may jitter the main raster projection, but the shadow planner consumes the unjittered `CameraFrustum` exclusively.
+1 -18
View File
@@ -40,7 +40,7 @@ check. For sanitizers, configure with `-DFASET_SANITIZERS=ON`, build the
`faset_lua_tests`, `faset_lua_safety_tests`, and `faset_schema_exporter` targets, then
run `ctest --test-dir <build> --output-on-failure -R '^lua_'`.
## Not verified in the September 18 run
## Not verified here
- Windows compilation or execution of the new module.
- Graphical/window interaction and real-time Lua reload in a running rendered game.
@@ -53,20 +53,3 @@ run `ctest --test-dir <build> --output-on-failure -R '^lua_'`.
The renderer-linked CPU checks used the existing Vulkan loader, repo-pinned Vulkan
headers and cached Slang, with SDL X11/Wayland disabled. No system graphics packages
were installed. This proves linkage and CPU validation, not graphics compatibility.
## September 24 Release follow-up
The [Release execution record](lua-release-2026-09-24/README.md) closes the
Linux offscreen export gap above. At source commit `4331857`, a Release Editor
exported the checked-in Lua-only sample into a disposable Unicode-path project.
The package was relocated, the source project path was hidden, and its Player
validated and rendered 120 frames on an NVIDIA GeForce RTX 2080 Ti with driver
595.84 and Khronos validation enabled; zero validation errors were reported.
The package manifest has `lua_enabled: true`, declared Lua source and its license,
and no project C++ gameplay source. The five focused Release Lua/schema tests
passed. This verifies an offscreen run on Linux; a separate physical Windows GPU
and manual window interaction have not been tested by this record. Windows
SwiftShader execution is configured in CI and needs a passing run before it is
claimed as covered.
![Relocated Lua sample after 120 frames](lua-release-2026-09-24/lua.png)
@@ -1,52 +0,0 @@
# Lua-only Release game: relocated execution
This is a local Linux result for source revision
`433185717e1f324acb6eaf186818589d11697453` on 2026-09-24. The
[machine-readable report](report.json) records the exact source input hashes,
Release package, Editor binary hash, command arguments and elapsed times. The
Editor itself was built with the `linux-release` preset. The exported Player
and package also identify their configuration as Release.
| Check | Observed result |
|---|---|
| Disposable source | `examples/lua` copied to a Unicode-path temporary project outside the engine tree |
| Package | `lua_enabled: true`; 32 hash-checked files, including Lua gameplay source and `Notices/lua/LICENSE.txt`; no project `Gameplay.cpp` or `.hpp` |
| Relocation | Generation copied outside the project/engine; original project path hidden before Player launch |
| CPU validation | Packaged Player `--validate` succeeded from an unrelated empty working directory |
| GPU execution | 120/120 headless 1280×720 frames, 0 validation errors |
| Device | NVIDIA GeForce RTX 2080 Ti; proprietary driver 595.84, Vulkan 1.4.329; selected-device details in the report and [Vulkan probe](vulkaninfo-summary.txt) |
| Image | Four expected scene colors, including cyan player and gold collectible; raw [PPM](lua.ppm) SHA-256 `e2f4c3b84de75dadd2e31d388d0ba51abc1861f1ec76eb7ba51a4a2dcac720a7` |
| Focused Release CTest | 5/5: `lua_contracts`, `lua_safety_contracts`, `lua_cli_contracts`, `lua_player_reload`, `build_schema_publication` |
The [manifest](lua-manifest.json) binds the package file checksums. The
[Player profile](lua-profile.json) contains all 120 per-frame samples and
resource/timing definitions; the [validate](lua-validate.json),
[render](lua-run.json) and [export](lua-export.json) logs retain raw process
results. The [PNG preview](lua.png) is a lossless conversion of the checked
PPM.
The verifier also confirmed a clean Git source tree, rejected undeclared
package files and unexpected C++ script files, classified the selected device
as physical, and found the sample Lua `on_start` log in the relocated run.
To repeat this check from a built checkout, choose a **new, empty** output
directory:
```sh
cmake --preset linux-release
cmake --build --preset linux-release --target faset_editor --parallel 4
python3 tools/verify_playable_exports.py --editor build/linux-release/faset_editor \
--output .cache/lua-release-check --only-lua
cmake --build --preset linux-release --target faset_player faset_schema_exporter \
faset_lua_tests faset_lua_safety_tests faset_build_schema_tests --parallel 4
ctest --test-dir build/linux-release --no-tests=error --output-on-failure \
-R '^(lua_contracts|lua_safety_contracts|lua_cli_contracts|lua_player_reload|build_schema_publication)$'
```
The render used the Player's synthetic fixed timestep and offscreen Vulkan
presentation. It does not establish manual window interaction or physical
Windows GPU compatibility. The Windows graphics workflow now includes this Lua
package beside both C++ examples under a pinned SwiftShader Vulkan driver; its
result must be recorded separately after CI completes.
![Lua game rendered by the relocated Release Player](lua.png)
File diff suppressed because one or more lines are too long
@@ -1,220 +0,0 @@
{
"asset_generations": {},
"build_fingerprint": "b745490f85f05987d849f6bb6f4d06953883f4b8e10c5551fadf164c7eec5857",
"configuration": "Release",
"executable": "faset_player",
"files": [
{
"path": "README.txt",
"sha256": "82bf72bc542643f6c4090e0a5de69b0014ca3fa17c6f9dfb12cc41e44c8b8db6",
"size": 262
},
{
"path": "Notices/Faset-NOTICE.txt",
"sha256": "30c2e7e80f5153b31ab57675e07246aef9165965d64e36022b426a26449ece3f",
"size": 204
},
{
"path": "Notices/dependencies.json",
"sha256": "262e8f63cf21c516829b1fedb0900ac90e76d62aa8f78d9ad02d8f24e0359e74",
"size": 2468
},
{
"path": "Notices/lua/LICENSE.txt",
"sha256": "e42d195c77d3ad7255ee9c874772d3c53998e7f60f9acf453d4630fa161e0bfe",
"size": 1126
},
{
"path": "Notices/stb/LICENSE",
"sha256": "bebfe904b14301657e4e5d655c811d51fd31b97c455b9cc2d8600d6bac6cff63",
"size": 2510
},
{
"path": "Notices/json/LICENSE.MIT",
"sha256": "46a65cffd1ea955132d95a8dd921640714a8d6b537d2e4e482d31145ae95b603",
"size": 1076
},
{
"path": "Notices/box3d/LICENSE",
"sha256": "da5e31a26bf3cfd5ba5c96d6823e480128c81e76c107ef9d3ee5d94789184b90",
"size": 1067
},
{
"path": "Notices/box2d/LICENSE",
"sha256": "68a3e676d7e94093b102d5cba0d4e04af812040d6f230c3db67a6664574e43d2",
"size": 1067
},
{
"path": "Notices/entt/LICENSE",
"sha256": "0785027ce472d7c61f05fba664a1d3ba6639c1593ced351ad9e4bed868765d99",
"size": 1097
},
{
"path": "Notices/sdl3/LICENSE.txt",
"sha256": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
"size": 884
},
{
"path": "shaders/gpuPostCullMain.reflection.json",
"sha256": "eed140bbe09c7d14935b688c770261ac0e81677c0974aa811c88ed67ea779b95",
"size": 3591
},
{
"path": "shaders/gpuHzbMain.reflection.json",
"sha256": "589818f670881c64f3aba726ee3bc82fe4b9d835a347f99134e0e157dc4a112e",
"size": 2050
},
{
"path": "shaders/gpuCullMain.reflection.json",
"sha256": "996fe5c7e31c6b8f316d8f290b3be0ae7063eb46c500a661e8715e9b822a2598",
"size": 3587
},
{
"path": "shaders/gpuShadowMain.reflection.json",
"sha256": "9a03a2b692d2b0c758f63fb27760352cfef20a068372ebc137f1ca1797dcd3c2",
"size": 2755
},
{
"path": "shaders/gpuVertexMain.reflection.json",
"sha256": "9f43472c267dbb6a2ddc69e252f7f00c5ca46f376d3c7c27165c8d12a175179d",
"size": 3098
},
{
"path": "shaders/gpuPostCullMain.spv",
"sha256": "62cb72e356346e1511c6d640a3cecb4a6e5283795338299b357edae5f7f52be4",
"size": 12460
},
{
"path": "shaders/gpuHzbMain.spv",
"sha256": "08682362ee2120ed19d260e613285071747c3e436bb09bb6238e46f9cedc2308",
"size": 4140
},
{
"path": "shaders/gpuCullMain.spv",
"sha256": "7691360ec55f41d40d43652f506b051cef8b3a3ddcdcc7db61a7d4d97f9e87d8",
"size": 16180
},
{
"path": "shaders/gpuShadowMain.spv",
"sha256": "ffcdb91e70c41ce9452094865546a009b9203140cf8eeb3ad1b4e5868d82b815",
"size": 2404
},
{
"path": "shaders/gpuVertexMain.spv",
"sha256": "4647ef4fe1a68ab623ee9c19dac56631907f825cba0becd30869c5221c2a0385",
"size": 4928
},
{
"path": "shaders/shadowMain.reflection.json",
"sha256": "e3fd0889cd2f569d410f5d7558a4091d9f57f247a1dcf1788462f67525340819",
"size": 2645
},
{
"path": "shaders/fragmentMain.reflection.json",
"sha256": "72ebf8d32452830650416dc4717d89a4324e97ece7028dfb97c66803c8dce71f",
"size": 2649
},
{
"path": "shaders/vertexMain.reflection.json",
"sha256": "c584f3b957ebf194a292451362a98590f85ff3cfbfd8f917b1c1265039381777",
"size": 2639
},
{
"path": "shaders/shadowMain.spv",
"sha256": "7643b4d688492b5ee923b9606f0b0e70343ca05fa67673838206119a6f86d8d8",
"size": 784
},
{
"path": "shaders/fragmentMain.spv",
"sha256": "f5c4f289917ecab5d00d543c053aac1d10e6cca311fd172daf06d315160a3505",
"size": 7436
},
{
"path": "shaders/vertexMain.spv",
"sha256": "1ad2631c35d654f48166321ae43d4165043e1b919b14f1dc61caf97b1ada0898",
"size": 1224
},
{
"path": "faset_player",
"sha256": "99d7b8286a9151ec4eff9a9899e862d987048b031e1e40ca1938646afbde37bd",
"size": 6329192
},
{
"path": "project.faset.json",
"sha256": "e07b12047f8141c3c4cc8f0e7d0905a5c635375e4494755e34191db0859ab86f",
"size": 175
},
{
"path": "Scripts/util/motion.lua",
"sha256": "bad3dcdc1081fd1dec589c12dad8dc175b739d77ea52b957ead9ecb4b7cb88a3",
"size": 150
},
{
"path": "Scripts/player.lua",
"sha256": "1c51810574a3413c46a7cbd0ea2e84a9b802da1206a2b72bb78f6cc951367a11",
"size": 1376
},
{
"path": "Scripts/beacon.lua",
"sha256": "44d932ebedf66cadb9620f2d926177be2f8d43d71d56aa83611de686af026231",
"size": 820
},
{
"path": "scene.fscene",
"sha256": "641ba14a1f9c3966b9ec797814fdfa54f954dc5e0d53ab00c645598edef61baf",
"size": 1681
}
],
"format": "faset.export",
"generation": "aa31bf16-984e-4854-b46f-15655bd6cc67",
"lua_enabled": true,
"lua_fingerprint": "a9261789bb1e0ca38bd40b08b0454165cd51454cba3ad4725dd357812ced6e35",
"platform": "linux",
"prerequisites": [
"Linux x86_64",
"Vulkan 1.3 driver",
"Compatible glibc and libstdc++ runtime"
],
"renderer_profile": {
"api": "Vulkan 1.3",
"materials": [
"base-color factor and texture",
"metallic and roughness factors"
],
"required_features": [
"dynamicRendering",
"synchronization2"
],
"shadow_map": {
"resolution": 1024,
"world_extent": 40
},
"texture_sampling": "linear clamp, one mip level",
"unsupported_material_features": [
"normal maps",
"metallic-roughness maps",
"emissive and occlusion maps",
"alpha mode selection",
"unlit mode",
"per-material face culling"
]
},
"scene_hash": "143724a468d711cbdf9a9700310d7b0acef003ce1b21422ca5204e0e565fa02c",
"simulation": {
"fixed_delta": 0.016666666666666666,
"gravity": [
0,
-9.81,
0
],
"max_catch_up_ticks": 4,
"physics_substeps": 4
},
"units": {
"angle": "radian",
"coordinates": "right-handed Y-up",
"distance": "metre"
},
"validation_log": "{\"dimension\":2,\"validated\":true}\n",
"version": 1
}
File diff suppressed because it is too large Load Diff
@@ -1,18 +0,0 @@
{
"arguments": [
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/faset_player",
"--headless",
"--frames",
"120",
"--capture",
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/verification.ppm",
"--profile",
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/profile.json"
],
"cwd": "/tmp/faset-playable-exports-8wzkrc_u/empty-working-directory",
"seconds": 0.6288121230027173,
"exit_code": 0,
"timed_out": false,
"stdout": "{\"device\":\"NVIDIA GeForce RTX 2080 Ti\",\"dimension\":2,\"effective_visibility_mode\":\"direct\",\"frames\":120,\"gpu_visibility_active\":false,\"ticks\":120,\"validation_errors\":0,\"visibility_mode\":\"direct\"}\n",
"stderr": "Lua player ready: A/D move, Space jump, E reset\n"
}
@@ -1,12 +0,0 @@
{
"arguments": [
"/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua/faset_player",
"--validate"
],
"cwd": "/tmp/faset-playable-exports-8wzkrc_u/empty-working-directory",
"seconds": 0.007359832990914583,
"exit_code": 0,
"timed_out": false,
"stdout": "{\"dimension\":2,\"validated\":true}\n",
"stderr": ""
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long
@@ -1,134 +0,0 @@
{
"format": "faset.playable-export-verification",
"version": 1,
"started_utc": "2026-09-23T23:30:12.719805+00:00",
"platform": "linux",
"engine": "/home/emil/Desktop/.worktrees/Faset_Engine-p1-lua-export",
"editor": "/home/emil/Desktop/.worktrees/Faset_Engine-p1-lua-export/build/linux-release/faset_editor",
"standalone_root": "/tmp/faset-playable-exports-8wzkrc_u",
"engine_revision": "433185717e1f324acb6eaf186818589d11697453",
"engine_dirty": false,
"engine_git_status": [],
"engine_diff_sha256": null,
"editor_sha256": "03ac75942f5f1d29e9d6fedb21ecffc6c74f4299401dbe84f01ab1b4e9c176a0",
"project_workspace": "/tmp/faset-playable-projects-cuhq4iwr",
"vulkaninfo_available": true,
"driver_override": null,
"frames_per_game": 120,
"status": "passed",
"projects": [
{
"name": "lua",
"dimension": 2,
"language": "lua",
"source_inputs": [
{
"path": "README.md",
"sha256": "8392da3c18916400ea628daed094c77c390c553cbdcf822da227bdcd3d86380c"
},
{
"path": "Scenes/main.scene.json",
"sha256": "f1ab54150a58b289c47bcc34566ad7ee3025ad931db81e7f5dce70d9fbcac8ab"
},
{
"path": "Scripts/beacon.lua",
"sha256": "44d932ebedf66cadb9620f2d926177be2f8d43d71d56aa83611de686af026231"
},
{
"path": "Scripts/player.lua",
"sha256": "1c51810574a3413c46a7cbd0ea2e84a9b802da1206a2b72bb78f6cc951367a11"
},
{
"path": "Scripts/util/motion.lua",
"sha256": "bad3dcdc1081fd1dec589c12dad8dc175b739d77ea52b957ead9ecb4b7cb88a3"
},
{
"path": "project.faset.json",
"sha256": "7e4c0781c1837208b4499a9991ffb702143edf1f933ae9c7e3d2543fd2577747"
}
],
"generation": "aa31bf16-984e-4854-b46f-15655bd6cc67",
"configuration": "Release",
"standalone_directory": "/tmp/faset-playable-exports-8wzkrc_u/Faset Café 世界/lua",
"executable": "faset_player",
"package_file_count": 32,
"asset_generations": {},
"device": "NVIDIA GeForce RTX 2080 Ti",
"device_class": "physical",
"driver": {
"apiVersion": "1.4.329",
"driverVersion": "595.84.0.0",
"deviceName": "NVIDIA GeForce RTX 2080 Ti",
"driverID": "DRIVER_ID_NVIDIA_PROPRIETARY",
"driverName": "NVIDIA",
"driverInfo": "595.84"
},
"validation_enabled": true,
"validation_errors": 0,
"completed_frames": 120,
"summary_ms": {
"gpu": {
"max": 0.584992,
"min": 0.551712,
"p50": 0.5608,
"p95": 0.574592,
"samples": 120
},
"render_call": {
"max": 4.928888,
"min": 1.544123,
"p50": 1.919881,
"p95": 2.423702,
"samples": 120
},
"renderer_cpu": {
"max": 4.923196,
"min": 1.540666,
"p50": 1.915753,
"p95": 2.420426,
"samples": 120
},
"renderer_readback_cpu": {
"max": 2.837363,
"min": 0.395756,
"p50": 0.504853,
"p95": 0.641169,
"samples": 120
},
"simulation": {
"max": 0.210056,
"min": 0.084289,
"p50": 0.122261,
"p95": 0.171062,
"samples": 120
},
"snapshot": {
"max": 0.254931,
"min": 0.046518,
"p50": 0.065754,
"p95": 0.087906,
"samples": 120
},
"wall": {
"max": 5.322149,
"min": 1.702973,
"p50": 2.142903,
"p95": 2.634219,
"samples": 120
}
},
"capture": {
"width": 1280,
"height": 720,
"sampled_colors": 4,
"sha256": "e2f4c3b84de75dadd2e31d388d0ba51abc1861f1ec76eb7ba51a4a2dcac720a7"
},
"source_project_paths_unavailable": true,
"status": "passed"
}
],
"requested_projects": [
"lua"
],
"finished_utc": "2026-09-23T23:32:18.151669+00:00"
}
@@ -1,82 +0,0 @@
==========
VULKANINFO
==========
Vulkan Instance Version: 1.4.341
Instance Extensions: count = 27
-------------------------------
VK_EXT_acquire_drm_display : extension revision 1
VK_EXT_acquire_xlib_display : extension revision 1
VK_EXT_debug_report : extension revision 10
VK_EXT_debug_utils : extension revision 2
VK_EXT_direct_mode_display : extension revision 1
VK_EXT_display_surface_counter : extension revision 1
VK_EXT_headless_surface : extension revision 1
VK_EXT_layer_settings : extension revision 2
VK_EXT_surface_maintenance1 : extension revision 1
VK_EXT_swapchain_colorspace : extension revision 5
VK_KHR_device_group_creation : extension revision 1
VK_KHR_display : extension revision 23
VK_KHR_external_fence_capabilities : extension revision 1
VK_KHR_external_memory_capabilities : extension revision 1
VK_KHR_external_semaphore_capabilities : extension revision 1
VK_KHR_get_display_properties2 : extension revision 1
VK_KHR_get_physical_device_properties2 : extension revision 2
VK_KHR_get_surface_capabilities2 : extension revision 1
VK_KHR_portability_enumeration : extension revision 1
VK_KHR_surface : extension revision 25
VK_KHR_surface_maintenance1 : extension revision 1
VK_KHR_surface_protected_capabilities : extension revision 1
VK_KHR_wayland_surface : extension revision 6
VK_KHR_xcb_surface : extension revision 6
VK_KHR_xlib_surface : extension revision 6
VK_LUNARG_direct_driver_loading : extension revision 1
VK_NV_display_stereo : extension revision 1
Instance Layers: count = 12
---------------------------
VK_LAYER_INTEL_nullhw INTEL NULL HW 1.1.73 version 1
VK_LAYER_KHRONOS_validation Khronos Validation Layer 1.4.341 version 1
VK_LAYER_MESA_anti_lag Open-source implementation of the VK_AMD_anti_lag extension. 1.4.303 version 1
VK_LAYER_MESA_device_select Linux device selection layer 1.4.303 version 1
VK_LAYER_MESA_overlay Mesa Overlay layer 1.4.303 version 1
VK_LAYER_MESA_screenshot Mesa Screenshot layer 1.4.303 version 1
VK_LAYER_NV_optimus NVIDIA Optimus layer 1.4.329 version 1
VK_LAYER_NV_present NVIDIA Presentation Layer 1.4.329 version 1
VK_LAYER_VALVE_steam_fossilize_32 Steam Pipeline Caching Layer 1.3.207 version 1
VK_LAYER_VALVE_steam_fossilize_64 Steam Pipeline Caching Layer 1.3.207 version 1
VK_LAYER_VALVE_steam_overlay_32 Steam Overlay Layer 1.3.207 version 1
VK_LAYER_VALVE_steam_overlay_64 Steam Overlay Layer 1.3.207 version 1
Devices:
========
GPU0:
apiVersion = 1.4.329
driverVersion = 595.84.0.0
vendorID = 0x10de
deviceID = 0x1e04
deviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU
deviceName = NVIDIA GeForce RTX 2080 Ti
driverID = DRIVER_ID_NVIDIA_PROPRIETARY
driverName = NVIDIA
driverInfo = 595.84
conformanceVersion = 1.4.3.3
deviceUUID = 2c71c7ba-97ac-c78e-2c06-af362631f3fe
driverUUID = 027cbdd1-c478-513e-af69-ebc9eaa7de87
GPU1:
apiVersion = 1.4.335
driverVersion = 26.0.8
vendorID = 0x10005
deviceID = 0x0000
deviceType = PHYSICAL_DEVICE_TYPE_CPU
deviceName = llvmpipe (LLVM 21.1.8, 256 bits)
driverID = DRIVER_ID_MESA_LLVMPIPE
driverName = llvmpipe
driverInfo = Mesa 26.0.8-1ubuntu0.3 (LLVM 21.1.8)
conformanceVersion = 1.3.1.1
deviceUUID = 6d657361-3236-2e30-2e38-2d3175627500
driverUUID = 6c6c766d-7069-7065-5555-494400000000
WARNING: [Loader Message] Code 0 : ICD for selected physical device does not export vkGetPhysicalDeviceDisplayPlanePropertiesKHR!
WARNING: [Loader Message] Code 0 : ICD for selected physical device does not export vkGetPhysicalDeviceDisplayPropertiesKHR!
@@ -1,42 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+233
View File
@@ -0,0 +1,233 @@
#include <faset/core/io.hpp>
#include <faset/render/renderer.hpp>
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
using namespace faset::render;
namespace fs = std::filesystem;
namespace {
struct Options {
unsigned lights{}, width{1920}, height{1080}, warmup{10}, frames{30}, run_index{};
bool shadows{}, validation{};
VisibilityMode visibility{VisibilityMode::Direct};
std::string commit{"unknown"}, driver{"unknown"};
fs::path csv, capture;
};
unsigned number(std::string_view text, std::string_view name) {
std::size_t end{};
const auto value = std::stoul(std::string(text), &end);
if (end != text.size() || value > 100000)
throw std::invalid_argument("Invalid value for " + std::string(name));
return static_cast<unsigned>(value);
}
Options parse(int argc, char** argv) {
Options options;
for (int i = 1; i < argc; ++i) {
const std::string name = argv[i];
if (name == "--list-runs") {
std::cout << "{\"lights\":[0,4,16,32,64,128],"
"\"visibility\":[\"direct\",\"gpu-frustum\",\"gpu-occlusion\"],"
"\"shadows\":[\"off\",\"on\"]}\n";
std::exit(0);
}
if (name == "--help") {
std::cout << "Usage: faset_p3_lighting_benchmark --lights 0|4|16|32|64|128 "
"--shadows on|off --visibility direct|gpu-frustum|gpu-occlusion "
"--csv PATH [--width N --height N --warmup N --frames N "
"--run-index N --commit SHA --driver NAME --validation on|off "
"--capture PATH]\n";
std::exit(0);
}
if (i + 1 >= argc)
throw std::invalid_argument("Missing value for " + name);
const std::string value = argv[++i];
if (name == "--lights") options.lights = number(value, name);
else if (name == "--width") options.width = number(value, name);
else if (name == "--height") options.height = number(value, name);
else if (name == "--warmup") options.warmup = number(value, name);
else if (name == "--frames") options.frames = number(value, name);
else if (name == "--run-index") options.run_index = number(value, name);
else if (name == "--commit") options.commit = value;
else if (name == "--driver") options.driver = value;
else if (name == "--csv") options.csv = faset::path_from_utf8(value);
else if (name == "--capture") options.capture = faset::path_from_utf8(value);
else if (name == "--shadows") {
if (value != "on" && value != "off")
throw std::invalid_argument("--shadows must be on or off");
options.shadows = value == "on";
} else if (name == "--validation") {
if (value != "on" && value != "off")
throw std::invalid_argument("--validation must be on or off");
options.validation = value == "on";
} else if (name == "--visibility") {
if (value == "direct") options.visibility = VisibilityMode::Direct;
else if (value == "gpu-frustum") options.visibility = VisibilityMode::GpuFrustum;
else if (value == "gpu-occlusion") options.visibility = VisibilityMode::GpuOcclusion;
else throw std::invalid_argument("Unknown visibility mode: " + value);
} else throw std::invalid_argument("Unknown option: " + name);
}
constexpr std::array allowed_lights{0u, 4u, 16u, 32u, 64u, 128u};
if (options.csv.empty() || options.width == 0 || options.height == 0 ||
options.frames == 0 || options.warmup > 1000 ||
std::find(allowed_lights.begin(), allowed_lights.end(), options.lights) ==
allowed_lights.end())
throw std::invalid_argument("Invalid benchmark configuration");
return options;
}
const char* mode_name(VisibilityMode mode) {
switch (mode) {
case VisibilityMode::Direct: return "direct";
case VisibilityMode::GpuFrustum: return "gpu-frustum";
case VisibilityMode::GpuOcclusion: return "gpu-occlusion";
}
return "unknown";
}
void csv_text(std::ostream& out, std::string_view value) {
out << '"';
for (const char c : value) {
if (c == '"') out << '"';
out << c;
}
out << '"';
}
Snapshot benchmark_scene(const Options& options) {
Snapshot scene;
scene.view_id = "p3-lighting-benchmark-fixed-scene";
scene.eye = {0, 0, 9};
scene.projection = perspective(.9f, float(options.width) / float(options.height), .1f, 100);
const auto view = look_at(scene.eye, {0, 0, 0});
scene.view_projection = multiply(scene.projection, view);
scene.camera_frustum = CameraFrustum{view, scene.projection, .1f, 100, true};
scene.authored_lights_present = true;
scene.clear_color = {.035f, .04f, .05f, 1};
DrawItem receiver;
receiver.mesh = cube_mesh();
receiver.model = transform({0, 0, -.15f}, {}, {10, 7.5f, .2f});
receiver.color = {.65f, .67f, .7f, 1};
receiver.roughness = .65f;
receiver.cast_shadow = options.shadows;
receiver.instance_key = "large-receiver";
scene.draws.push_back(receiver);
for (int i = 0; i < 9; ++i) {
DrawItem object;
object.mesh = cube_mesh();
object.model = transform({(float(i % 3) - 1.f) * 2.5f,
(float(i / 3) - 1.f) * 1.8f, .45f},
{}, {.42f, .42f, .6f});
object.color = {.6f + .1f * float(i % 3), .5f, .4f + .1f * float(i / 3), 1};
object.cast_shadow = options.shadows;
object.instance_key = "caster-" + std::to_string(i);
scene.draws.push_back(std::move(object));
}
for (unsigned i = 0; i < options.lights; ++i) {
LocalLight light;
light.kind = LocalLight::Kind::Point;
light.stable_id = "benchmark-light-" + std::to_string(i);
light.position = {(float(i % 8) - 3.5f) * 1.35f,
(float((i / 8) % 8) - 3.5f) * .95f,
2.f + .35f * float(i % 3)};
light.color = {.6f + .4f * float(i % 3 == 0),
.6f + .4f * float(i % 3 == 1),
.6f + .4f * float(i % 3 == 2), 1};
light.intensity = 5.f;
light.range = 8.f;
light.casts_shadow = options.shadows;
scene.local_lights.push_back(std::move(light));
}
return scene;
}
void benchmark(const Options& options) {
RendererConfig config;
config.width = options.width;
config.height = options.height;
config.headless = true;
config.validation = options.validation;
config.visibility_mode = options.visibility;
auto renderer = Renderer(config);
const auto scene = benchmark_scene(options);
for (unsigned i = 0; i < options.warmup; ++i)
renderer.render(scene);
if (!options.csv.parent_path().empty())
fs::create_directories(faset::native_io_path(options.csv.parent_path()));
std::ofstream csv(faset::native_io_path(options.csv));
if (!csv)
throw std::runtime_error("Cannot open benchmark CSV: " + faset::path_to_utf8(options.csv));
csv << "light_count,shadows,visibility,effective_visibility,lighting_path,"
"build_configuration,run_index,frame,"
"device,driver,commit,width,height,validation_enabled,validation_errors,"
"submitted_local_lights,omitted_local_lights,shadow_tiles,draw_calls,gpu_bytes,"
"gpu_main_raster_ms,gpu_post_raster_ms,gpu_post_visible,visibility_counters_valid,"
"gpu_shadow_ms,gpu_ms,cpu_ms,readback_cpu_ms\n";
csv << std::fixed << std::setprecision(6);
for (unsigned frame = 0; frame < options.frames; ++frame) {
renderer.render(scene);
const auto stats = renderer.stats();
if (stats.validation_errors != 0)
throw std::runtime_error("Vulkan validation error during benchmark");
if (stats.submitted_local_lights != options.lights || stats.omitted_local_lights != 0)
throw std::runtime_error("Renderer did not submit every requested local light");
if (stats.effective_visibility_mode != options.visibility)
throw std::runtime_error("Requested visibility path fell back during benchmark");
if (stats.gpu_main_raster_ms <= 0 || stats.gpu_ms <= 0)
throw std::runtime_error("GPU raster or frame timestamp was unavailable");
csv << options.lights << ',' << (options.shadows ? "on" : "off") << ','
<< mode_name(options.visibility) << ',' << mode_name(stats.effective_visibility_mode)
<< ",forward," << FASET_BENCHMARK_CONFIGURATION << ','
<< options.run_index << ',' << frame << ',';
csv_text(csv, stats.device);
csv << ',';
csv_text(csv, options.driver);
csv << ',';
csv_text(csv, options.commit);
csv << ',' << options.width << ',' << options.height << ','
<< (stats.validation_enabled ? 1 : 0) << ',' << stats.validation_errors << ','
<< stats.submitted_local_lights << ',' << stats.omitted_local_lights
<< ",0," << stats.draw_calls << ',' << stats.gpu_allocated_bytes << ','
<< stats.gpu_main_raster_ms << ',' << stats.gpu_post_raster_ms << ','
<< stats.gpu_post_visible << ',' << (stats.visibility_counters_valid ? 1 : 0)
<< ",0," << stats.gpu_ms << ','
<< stats.cpu_ms << ',' << stats.readback_cpu_ms << '\n';
}
if (!csv)
throw std::runtime_error("Cannot finish benchmark CSV: " + faset::path_to_utf8(options.csv));
if (!options.capture.empty())
renderer.capture(faset::native_io_path(options.capture));
}
} // namespace
int benchmark_main(int argc, char** argv) {
try {
benchmark(parse(argc, argv));
return 0;
} catch (const std::exception& error) {
std::cerr << "P3 lighting benchmark: " << error.what() << '\n';
return 1;
}
}
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) {
return faset::run_utf8_main(argc, argv, benchmark_main);
}
#else
int main(int argc, char** argv) {
return benchmark_main(argc, argv);
}
#endif
+1 -2
View File
@@ -25,8 +25,7 @@ class AuthoringService {
const Json& operations, const std::string& idempotency_key = "");
Json undo(const std::string& document, std::uint64_t expected_revision);
Json redo(const std::string& document, std::uint64_t expected_revision);
Json save(const std::string& document, const std::filesystem::path& relative = {},
std::optional<std::uint64_t> expected_revision = std::nullopt);
Json save(const std::string& document, const std::filesystem::path& relative = {});
Json recovery_documents() const;
Json recover(const std::string& document,
std::optional<std::uint64_t> expected_revision = std::nullopt);
-35
View File
@@ -1,35 +0,0 @@
#pragma once
#include <faset/core/json.hpp>
#include <chrono>
#include <functional>
#include <map>
#include <mutex>
#include <string>
namespace faset::editor {
class AutosaveController {
public:
using Clock = std::chrono::steady_clock;
using SaveFn = std::function<Json(const std::string&, std::uint64_t)>;
explicit AutosaveController(SaveFn save);
// Called from the Editor event loop with an injected monotonic clock for
// deterministic tests. Only a named dirty scene becomes eligible to save.
void observe(const Json& documents, Clock::time_point now, bool enabled);
Json status() const;
private:
struct Entry {
std::uint64_t revision{};
std::string path, state = "saved", error;
Clock::time_point deadline{};
};
SaveFn save_;
mutable std::mutex mutex_;
std::map<std::string, Entry> entries_;
bool enabled_ = true;
};
} // namespace faset::editor
-52
View File
@@ -1,52 +0,0 @@
#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
@@ -1,15 +0,0 @@
#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,7 +3,6 @@
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace faset::editor {
@@ -23,7 +22,6 @@ 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";
@@ -39,9 +37,6 @@ 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.
+2 -7
View File
@@ -8,7 +8,6 @@ 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);
@@ -16,9 +15,7 @@ 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::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
const std::filesystem::path& recent_store = {});
~ProjectLauncher();
void frame(const std::vector<render::Event>&);
const render::Snapshot& snapshot() const;
@@ -33,7 +30,5 @@ 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 = {},
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {});
} // namespace faset::editor
-5
View File
@@ -2,12 +2,10 @@
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/editor/autosave.hpp>
#include <faset/editor/commands.hpp>
#include <faset/editor/plugins.hpp>
#include <memory>
#include <mutex>
#include <string_view>
#include <thread>
namespace faset::editor {
@@ -35,7 +33,6 @@ 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_;
}
@@ -63,8 +60,6 @@ class Session {
Commands commands_;
assets::AssetPipeline assets_;
BuildService builds_;
AutosaveController autosave_;
bool autosave_enabled_ = true;
std::map<std::string, std::shared_ptr<ImportTask>> imports_;
std::vector<std::jthread> workers_;
std::vector<std::string> logs_;
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <faset/render/visibility.hpp>
#include <cstddef>
#include <cstdint>
#include <span>
#include <string>
#include <vector>
namespace faset::render {
enum class ShadowDropReason { None, Unavailable, TileBudget, CasterBudget };
enum class ShadowRedrawReason { EveryFrame };
struct ShadowBudget {
std::uint32_t max_sun_views{4};
std::uint32_t max_local_faces{16};
std::uint32_t max_caster_draws{4096};
std::uint32_t max_local_lights{128};
std::uint32_t sun_atlas_size{2048};
std::uint32_t local_atlas_size{2048};
float max_shadow_distance{80};
bool sun_atlas_available{true};
bool local_atlas_available{true};
};
struct ShadowCasterBounds {
Bounds world;
std::uint32_t draw_index{}; // Index of the original source LOD-0 DrawItem.
};
struct ShadowView {
enum class Kind { Sun, Spot, Point };
Kind kind{Kind::Sun};
std::string light_id;
std::uint32_t face_index{};
std::uint32_t tile_index{};
std::uint32_t tile_origin_x{}, tile_origin_y{}, tile_size{}, usable_size{};
Mat4 view_projection{identity};
std::array<float, 4> atlas_scale_offset{};
std::array<float, 4> guarded_clamp{};
float split_near{}, split_far{};
float snapped_center_x{}, snapped_center_y{};
std::vector<std::uint32_t> caster_indices;
bool valid{};
ShadowDropReason reason{ShadowDropReason::None};
ShadowRedrawReason redraw_reason{ShadowRedrawReason::EveryFrame};
};
struct LocalShadowAssignment {
std::size_t source_index{};
std::uint32_t first_view{};
std::uint32_t face_count{};
bool valid{};
ShadowDropReason reason{ShadowDropReason::None};
};
struct ShadowPlan {
std::vector<ShadowView> sun_views; // Requested slots, including explicitly invalid ones.
std::vector<ShadowView> local_views; // Only complete, valid spot/point allocations.
std::vector<std::size_t> submitted_local_indices; // Priority/influence/stable-ID order.
std::vector<LocalShadowAssignment> local_assignments;
std::uint32_t requested_sun_cascades{}, effective_sun_cascades{};
std::uint32_t local_faces_requested{}, local_faces_used{};
std::uint32_t dropped_sun_views{}, dropped_local_faces{}, dropped_point_faces{};
std::uint32_t omitted_local_lights{}, caster_draws{};
std::uint32_t sun_atlas_size{}, local_atlas_size{};
};
// Stateless: every scheduled tile is cleared/redrawn; no prior-frame depth or
// ownership is reused. Shadow casters are source LOD-0 world bounds, independent
// of the camera/P2 visibility decision. This function performs no Vulkan work.
ShadowPlan build_shadow_plan(const Snapshot& frame,
std::span<const ShadowCasterBounds> casters,
const ShadowBudget& budget = {});
} // namespace faset::render
+35
View File
@@ -80,6 +80,34 @@ struct Text {
Color color{0.85f, 0.87f, 0.90f, 1};
float size{14};
};
struct SunLight {
std::string stable_id;
Vec3 direction{-0.5f, -1, -0.3f};
Color color{1, 1, 1, 1};
float intensity{1};
bool casts_shadow{true};
};
struct LocalLight {
enum class Kind { Point, Spot };
Kind kind{Kind::Point};
std::string stable_id;
Vec3 position{};
Vec3 direction{0, 0, -1};
Color color{1, 1, 1, 1};
float intensity{1};
float range{10};
float inner_angle{0.35f};
float outer_angle{0.7f};
bool casts_shadow{true};
int shadow_priority{};
};
struct CameraFrustum {
Mat4 view{identity};
Mat4 projection{identity};
float near_plane{0.1f};
float far_plane{1000};
bool perspective{true};
};
struct Snapshot {
// Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full
// target.
@@ -100,6 +128,12 @@ struct Snapshot {
// Distinguishes temporal histories when one Renderer displays different views.
std::string view_id{};
bool camera_cut{};
// Empty legacy scenes may use the renderer's compatibility sun. Any authored light
// component, including an explicitly disabled or opaque future one, suppresses it.
bool authored_lights_present{};
std::optional<SunLight> sun{};
std::vector<LocalLight> local_lights;
std::optional<CameraFrustum> camera_frustum{};
};
enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion };
// CPU-only validation used before publishing a game or creating Vulkan pipelines.
@@ -149,6 +183,7 @@ struct FrameStats {
std::uint64_t gpu_allocated_bytes{};
std::uint32_t texture_count{};
std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{};
std::uint32_t submitted_local_lights{}, omitted_local_lights{};
bool gpu_visibility_active{}, hzb_valid{};
// Requested and actual paths for the last frame; actual may be less capable.
VisibilityMode requested_visibility_mode{VisibilityMode::Direct};
+1
View File
@@ -44,6 +44,7 @@ nav:
- Editor workspace: editor/workspace.md
- Scene templates: editor/templates.md
- Assets and Blender: editor/assets.md
- Lighting: editor/lighting.md
- GPU visibility and mesh LOD: editor/visibility-lod.md
- Build, Play, and export: editor/export.md
- Profiling and measurements: editor/profiling.md
+85 -19
View File
@@ -24,6 +24,32 @@ struct FrameParameters {
[[vk::binding(1,0)]] SamplerState shadowSampler;
[[vk::binding(2,0)]] Texture2D<float4> colorMap;
[[vk::binding(3,0)]] SamplerState colorSampler;
// Shared Direct/P2 graphics ABI. The legacy material set remains set 0;
// GPU-only instance/visibility records occupy set 2.
struct LightingHeader {
uint4 counts; // local count, sun enabled, sun shadow enabled, view count
float4 sunDirectionIntensity; // xyz world-space ray direction, w intensity
float4 sunColor;
float4 cameraForwardShadowDistance;
float4 cascadeSplits;
};
struct LocalLightGpu {
float4 positionRange;
float4 directionCosOuter;
float4 colorIntensity;
float4 coneTypeShadowView; // cos(inner), 0=point/1=spot, shadow view, flags
float4 reserved;
};
struct ShadowViewGpu {
column_major float4x4 viewProjection;
float4 tileScaleOffset;
float4 guardedClamp;
float4 biasFlags;
};
[[vk::binding(0,1)]] StructuredBuffer<LightingHeader> lightingFrame;
[[vk::binding(1,1)]] StructuredBuffer<LocalLightGpu> localLights;
[[vk::binding(2,1)]] StructuredBuffer<ShadowViewGpu> shadowViews;
[[vk::binding(3,1)]] Texture2D<float> localShadowAtlas;
[shader("vertex")]
VertexOutput vertexMain(VertexInput v) {
VertexOutput o;
@@ -32,6 +58,22 @@ VertexOutput vertexMain(VertexInput v) {
}
[shader("vertex")]
float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); }
float3 directBRDF(float3 base, float rough, float metal, float3 n, float3 view, float3 l) {
const float pi = 3.14159265;
float nl = max(dot(n,l),0.0);
if (nl <= 0.0) return float3(0);
float3 halfVector = l + view;
float halfLengthSquared = dot(halfVector, halfVector);
float3 h = halfLengthSquared > 1e-8 ? halfVector * rsqrt(halfLengthSquared) : n;
float nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0);
float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0;
float d=a2/(pi*denom*denom+0.0001);
float k=(rough+1.0)*(rough+1.0)/8.0;
float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k));
float3 f0=lerp(float3(0.04),base,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0);
float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001);
return ((1.0-fresnel)*(1.0-metal)*base/pi+spec)*nl;
}
[shader("fragment")]
float4 fragmentMain(VertexOutput v) : SV_Target {
float4 sampled = colorMap.Sample(colorSampler, v.uv);
@@ -41,28 +83,52 @@ float4 fragmentMain(VertexOutput v) : SV_Target {
return v.color * sampled;
}
float4 base = v.color * sampled;
const float pi = 3.14159265;
float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view);
float nl=max(dot(n,l),0.0), nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0);
LightingHeader lighting = lightingFrame[0];
float3 n=normalize(v.normal);
float3 viewDelta=frame.eye.xyz-v.world;
float viewLengthSquared=dot(viewDelta,viewDelta);
float3 view=viewLengthSquared > 1e-8 ? viewDelta*rsqrt(viewLengthSquared) : n;
float rough=clamp(v.material.x,0.08,1.0), metal=saturate(v.material.y);
float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0;
float d=a2/(pi*denom*denom+0.0001);
float k=(rough+1.0)*(rough+1.0)/8.0;
float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k));
float3 f0=lerp(float3(0.04),base.rgb,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0);
float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001);
float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1));
float3 projected=lightClip.xyz/lightClip.w;
float2 uv=projected.xy*.5+.5;
float visibility=1.0;
if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) {
visibility=0.0;
for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) {
float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0);
visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0;
float3 linear=base.rgb*.12;
if (lighting.counts.y != 0 && lighting.sunDirectionIntensity.w > 0) {
float3 l=normalize(-lighting.sunDirectionIntensity.xyz);
float nl=max(dot(n,l),0.0);
float visibility=1.0;
if (lighting.counts.z != 0 && nl > 0) {
float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1));
float3 projected=lightClip.xyz/lightClip.w;
float2 uv=projected.xy*.5+.5;
if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) {
visibility=0.0;
for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) {
float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0);
visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0;
}
}
}
linear += directBRDF(base.rgb, rough, metal, n, view, l) *
lighting.sunColor.rgb * (lighting.sunDirectionIntensity.w * 3.0 * visibility);
}
for (uint i=0; i<lighting.counts.x; ++i) {
LocalLightGpu light=localLights[i];
float3 delta=light.positionRange.xyz-v.world;
float distanceSquared=max(dot(delta,delta),1e-6);
float distance=sqrt(distanceSquared);
float range=max(light.positionRange.w,1e-4);
if (distance >= range || light.colorIntensity.w <= 0) continue;
float3 l=delta/distance;
float relative=distance/range;
float cutoff=1.0-relative*relative*relative*relative;
float attenuation=cutoff*cutoff/(1.0+distanceSquared);
if (light.coneTypeShadowView.y > 0.5) {
float cosAngle=dot(-l,normalize(light.directionCosOuter.xyz));
float denominator=max(light.coneTypeShadowView.x-light.directionCosOuter.w,1e-4);
float cone=saturate((cosAngle-light.directionCosOuter.w)/denominator);
attenuation *= cone*cone*(3.0-2.0*cone);
}
linear += directBRDF(base.rgb, rough, metal, n, view, l) *
light.colorIntensity.rgb * (light.colorIntensity.w * attenuation);
}
float3 linear=base.rgb*.12 + ((1.0-fresnel)*(1.0-metal)*base.rgb/pi+spec)*nl*3.0*visibility;
linear=linear/(1.0+linear);
return float4(pow(max(linear,0),float3(1.0/2.2)),base.a);
}
+3 -3
View File
@@ -57,9 +57,9 @@ struct GpuFrameParameters {
uint4 drawInfo; // x=visible ID range base; firstInstance is always zero
};
[[vk::push_constant]] ConstantBuffer<GpuFrameParameters> gpuFrame;
[[vk::binding(0,1)]] StructuredBuffer<InstanceRecord> gfxInstances;
[[vk::binding(1,1)]] StructuredBuffer<uint> gfxVisibleIds;
[[vk::binding(2,1)]] StructuredBuffer<ViewRecord> gfxViews;
[[vk::binding(0,2)]] StructuredBuffer<InstanceRecord> gfxInstances;
[[vk::binding(1,2)]] StructuredBuffer<uint> gfxVisibleIds;
[[vk::binding(2,2)]] StructuredBuffer<ViewRecord> gfxViews;
// All indirect commands use firstInstance=0. The raw Vulkan index avoids the
// BaseInstance read that Slang adds for SV_InstanceID (DrawParameters feature).
+28 -3
View File
@@ -156,6 +156,15 @@ void SchemaRegistry::validate_component(const Json& component) const {
for (const auto& [id, value] : component["fields"].items())
if (metadata["fields"].contains(id))
validate_field(value, metadata["fields"][id]);
if (type == "faset.light") {
auto effective = default_fields(type);
effective.update(component.at("fields"));
if (effective.at("kind") == "spot")
require(effective.at("inner_angle").get<double>() <=
effective.at("outer_angle").get<double>(),
"validation.light_cone",
"Spotlight inner_angle must not exceed outer_angle");
}
}
void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) {
require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) &&
@@ -252,9 +261,25 @@ SchemaRegistry builtin_schemas() {
{{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}},
{"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}},
{"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}});
add("faset.light", "Directional Light",
{{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}});
add("faset.light", "Light",
{{"kind", Json{{"type", "string"},
{"default", "directional"},
{"enum", {"directional", "point", "spot"}}}},
{"enabled", field("boolean", true)},
{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}},
{"range", Json{{"type", "number"}, {"default", 10.0}, {"min", 0.001}}},
{"inner_angle", Json{{"type", "number"},
{"default", 0.35}, {"min", 0.0}, {"max", 1.55},
{"unit", "radians"}}},
{"outer_angle", Json{{"type", "number"},
{"default", 0.7}, {"min", 0.001}, {"max", 1.55},
{"unit", "radians"}}},
{"casts_shadow", field("boolean", true)},
{"shadow_priority", Json{{"type", "integer"},
{"default", 0},
{"min", std::numeric_limits<int>::min()},
{"max", std::numeric_limits<int>::max()}}}});
for (int dimension : {2, 3}) {
Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0};
Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5};
+1 -4
View File
@@ -573,12 +573,9 @@ Json AuthoringService::undo(const std::string& id, std::uint64_t revision) {
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
return history(id, revision, true);
}
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative,
std::optional<std::uint64_t> expected_revision) {
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) {
std::lock_guard lock(mutex_);
auto& current = state(id);
if (expected_revision)
check_revision(current.revision, *expected_revision);
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
require(!selected.empty(), "save.path", "Choose a scene path before saving");
const auto path = project_path(root_, selected);
-88
View File
@@ -1,88 +0,0 @@
#include <faset/editor/autosave.hpp>
#include <faset/core/error.hpp>
#include <set>
#include <stdexcept>
namespace faset::editor {
AutosaveController::AutosaveController(SaveFn save) : save_(std::move(save)) {
if (!save_)
throw std::invalid_argument("Autosave requires a save callback");
}
void AutosaveController::observe(const Json& documents, Clock::time_point now, bool enabled) {
std::lock_guard lock(mutex_);
enabled_ = enabled;
std::set<std::string> present;
for (const auto& document : documents) {
const auto id = document.at("id").get<std::string>();
const auto revision = document.at("revision").get<std::uint64_t>();
const auto path = document.value("path", std::string());
const auto dirty = document.value("dirty", false);
present.insert(id);
auto [found, inserted] = entries_.try_emplace(id);
auto& entry = found->second;
const auto changed = inserted || entry.revision != revision || entry.path != path;
const auto prior_state = entry.state;
entry.revision = revision;
entry.path = path;
if (!dirty) {
entry.state = "saved";
entry.error.clear();
continue;
}
if (path.empty()) {
entry.state = "save_as_required";
entry.error.clear();
continue;
}
if (!enabled) {
entry.state = "disabled";
entry.error.clear();
continue;
}
if (changed || prior_state == "disabled" || prior_state == "saved" ||
prior_state == "save_as_required") {
entry.state = "pending";
entry.error.clear();
entry.deadline = now + std::chrono::seconds(2);
}
if (entry.state != "pending" || now < entry.deadline)
continue;
entry.state = "saving";
try {
const auto saved = save_(id, revision);
if (saved.at("revision").get<std::uint64_t>() != revision ||
saved.at("dirty").get<bool>())
throw std::runtime_error("Save returned an unexpected document revision");
entry.state = "saved";
entry.error.clear();
} catch (const Error& error) {
entry.state = error.code() == "save.disk_conflict" ||
error.code() == "revision.conflict"
? "conflict"
: "failed";
entry.error = error.what();
} catch (const std::exception& error) {
entry.state = "failed";
entry.error = error.what();
}
}
for (auto it = entries_.begin(); it != entries_.end();)
it = present.contains(it->first) ? std::next(it) : entries_.erase(it);
}
Json AutosaveController::status() const {
std::lock_guard lock(mutex_);
Json documents = Json::array();
for (const auto& [id, entry] : entries_)
documents.push_back({{"id", id},
{"state", entry.state},
{"revision", entry.revision},
{"path", entry.path},
{"error", entry.error}});
return {{"enabled", enabled_}, {"documents", documents}};
}
} // namespace faset::editor
-393
View File
@@ -1,393 +0,0 @@
#include <faset/editor/build_cache.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <cctype>
#include <cstdlib>
#include <set>
#include <stdexcept>
namespace faset::editor {
namespace fs = std::filesystem;
namespace {
std::string normalized_hash(const Json& value) {
return sha256(value.dump());
}
std::pair<std::string, std::string> parse_define(const std::string& argument) {
if (!argument.starts_with("-D"))
throw std::invalid_argument("Gameplay configure arguments must be -DNAME=value options");
const auto equal = argument.find('=', 2);
if (equal == std::string::npos || equal == 2)
throw std::invalid_argument("Invalid gameplay configure definition");
const auto colon = argument.find(':', 2);
const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2);
if (key.empty())
throw std::invalid_argument("Invalid gameplay configure definition");
return {key, argument.substr(equal + 1)};
}
void validate_configure_arguments(const BuildConfig& config) {
static const std::set<std::string> controlled = {
"FASET_GAMEPLAY_SOURCE_DIR", "FASET_ENABLE_LUA", "CMAKE_BUILD_TYPE", "BUILD_TESTING",
"FASET_BUILD_EDITOR", "FASET_BUILD_RENDERER", "FASET_BUILD_RUNTIME", "FASET_BUILD_ASSETS"};
for (const auto& argument : config.configure_arguments)
if (controlled.contains(parse_define(argument).first))
throw std::invalid_argument("Cannot override an engine-controlled CMake definition");
}
std::string configured_tool(const BuildConfig& config, std::string_view key,
std::string fallback) {
if (auto value = configured_cmake_value(config, key))
return *value;
return fallback;
}
fs::path resolve_tool(const std::string& name, const fs::path& working_directory) {
const auto supplied = path_from_utf8(name);
if (supplied.has_parent_path())
return (supplied.is_absolute() ? supplied : working_directory / supplied).lexically_normal();
try {
return find_executable(name);
} catch (const std::exception&) {
// CMake reports a missing compiler. Keep the identity deterministic so
// fixture build tools can exercise publication without native compiling.
return supplied;
}
}
Json tool_identity(const std::string& name, const fs::path& working_directory) {
const auto resolved = resolve_tool(name, working_directory);
Json result = {{"path", path_to_utf8(resolved)}};
if (fs::is_regular_file(resolved))
result["sha256"] = sha256_file(resolved);
else
result["missing"] = true;
return result;
}
std::string slang_name(const BuildConfig& config) {
auto configured = configured_tool(config, "SLANGC_EXECUTABLE", "");
if (!configured.empty())
return configured;
#ifdef _WIN32
const auto local = config.engine_root / ".cache/slang/bin/slangc.exe";
#else
const auto local = config.engine_root / ".cache/slang/bin/slangc";
#endif
if (fs::is_regular_file(local))
return path_to_utf8(local);
if (const char* sdk = std::getenv("VULKAN_SDK")) {
#ifdef _WIN32
const auto sdk_tool = path_from_utf8(sdk) / "Bin/slangc.exe";
#else
const auto sdk_tool = path_from_utf8(sdk) / "bin/slangc";
#endif
if (fs::is_regular_file(sdk_tool))
return path_to_utf8(sdk_tool);
}
return "slangc";
}
Json recipe_files(const fs::path& engine_root) {
Json files = Json::object();
for (const auto& relative : {"CMakeLists.txt", "dependencies.lock.json",
"tools/compile_shader.py"}) {
const auto path = engine_root / relative;
files[relative] = fs::is_regular_file(path) ? Json(sha256_file(path)) : Json(nullptr);
}
const auto cmake = engine_root / "cmake";
if (fs::is_directory(cmake))
for (const auto& entry : fs::directory_iterator(cmake))
if (entry.is_regular_file() && entry.path().extension() == ".cmake")
files[generic_path_to_utf8(entry.path().lexically_relative(engine_root))] =
sha256_file(entry.path());
return files;
}
bool within(const fs::path& path, const fs::path& directory) {
const auto relative = path.lexically_relative(directory);
if (relative.empty() || relative.is_absolute())
return false;
for (const auto& component : relative)
if (component == "..")
return false;
return true;
}
void refuse_symlink_ancestors(const fs::path& path) {
fs::path prefix;
for (const auto& component : path) {
prefix /= component;
std::error_code error;
if (fs::is_symlink(fs::symlink_status(prefix, error)))
throw std::runtime_error("Native build path contains a symlink: " +
path_to_utf8(prefix));
}
}
} // namespace
std::optional<std::string> configured_cmake_value(const BuildConfig& config,
std::string_view key) {
std::optional<std::string> result;
for (const auto& argument : config.configure_arguments) {
const auto [name, value] = parse_define(argument);
if (name == key)
result = value;
}
return result;
}
std::string BuildInputs::fingerprint() const {
return normalized_hash({{"format", "faset.build-inputs.v1"},
{"source", source_hash},
{"recipe", recipe_hash},
{"toolchain", toolchain_hash}});
}
std::string gameplay_source_hash(const fs::path& project_root, const scripting::LuaProject& lua) {
Json files = Json::object();
const auto scripts = project_root / "Scripts";
if (fs::is_symlink(fs::symlink_status(scripts)))
throw std::runtime_error("Scripts directory must not be a symlink");
if (fs::is_directory(scripts))
for (const auto& entry : fs::recursive_directory_iterator(scripts)) {
if (entry.is_symlink())
throw std::runtime_error("Scripts source cannot be a symlink: " +
path_to_utf8(entry.path()));
if (entry.is_regular_file())
files[generic_path_to_utf8(entry.path().lexically_relative(project_root))] =
sha256_file(entry.path());
}
return normalized_hash({{"format", "faset.gameplay-sources.v1"},
{"files", files},
{"lua", lua.fingerprint}});
}
BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua) {
validate_configure_arguments(config);
BuildInputs result;
result.source_hash = gameplay_source_hash(config.project_root, lua);
result.recipe_hash = normalized_hash({{"format", "faset.build-recipe.v1"},
{"generator", config.generator},
{"configuration", config.configuration},
{"export_configuration", config.export_configuration},
{"configure_arguments", config.configure_arguments},
{"engine_files", recipe_files(config.engine_root)}});
#ifdef _WIN32
constexpr auto default_c = "clang-cl", default_cxx = "clang-cl";
#else
constexpr auto default_c = "clang", default_cxx = "clang++";
#endif
result.toolchain_hash = normalized_hash(
{{"format", "faset.native-toolchain.v1"},
{"cmake", tool_identity(config.cmake, config.project_root)},
{"c", tool_identity(configured_tool(config, "CMAKE_C_COMPILER", default_c),
config.project_root)},
{"cxx", tool_identity(configured_tool(config, "CMAKE_CXX_COMPILER", default_cxx),
config.project_root)},
{"slang", tool_identity(slang_name(config), config.project_root)},
{"toolchain_file", tool_identity(configured_tool(config, "CMAKE_TOOLCHAIN_FILE", ""),
config.project_root)}});
return result;
}
fs::path stage_gameplay_sources(const BuildConfig& config, const BuildInputs& inputs,
const scripting::LuaProject& lua, std::string_view job_id) {
const auto cache = config.cache_root.empty() ? config.project_root / ".faset" / "cache"
: config.cache_root;
const auto snapshots = cache / "source-snapshots";
const auto destination = snapshots / inputs.source_hash;
const auto verified = [&](const fs::path& root) {
return fs::is_directory(root / "Scripts") && !fs::is_symlink(root) &&
gameplay_source_hash(root, lua) == inputs.source_hash;
};
if (fs::exists(destination)) {
if (!verified(destination))
throw std::runtime_error("Cached gameplay source snapshot is corrupt");
return destination / "Scripts";
}
const auto source = config.project_root / "Scripts";
if (!fs::is_directory(source) || fs::is_symlink(source))
throw std::runtime_error("Gameplay Scripts directory is missing or a symlink");
const auto staging = snapshots / (".staging-" + std::string(job_id));
if (fs::exists(staging))
throw std::runtime_error("Gameplay source staging directory already exists");
fs::create_directories(staging / "Scripts");
try {
for (const auto& entry : fs::recursive_directory_iterator(source)) {
if (entry.is_symlink())
throw std::runtime_error("Gameplay source snapshot contains a symlink");
const auto target = staging / "Scripts" / entry.path().lexically_relative(source);
if (entry.is_directory())
fs::create_directories(target);
else if (entry.is_regular_file()) {
fs::create_directories(target.parent_path());
fs::copy_file(entry.path(), target);
}
}
if (!verified(staging))
throw std::runtime_error("Gameplay sources changed while creating the build snapshot");
fs::rename(staging, destination);
return destination / "Scripts";
} catch (...) {
std::error_code ignored;
fs::remove_all(staging, ignored);
if (fs::exists(destination) && verified(destination))
return destination / "Scripts";
throw;
}
}
void ensure_native_toolchain_stamp(const BuildConfig& config, const fs::path& native_directory,
const BuildInputs& inputs) {
const auto project = fs::absolute(config.project_root).lexically_normal();
const auto base = fs::absolute(config.build_directory).lexically_normal();
const auto native = fs::absolute(native_directory).lexically_normal();
const auto cache = fs::absolute(config.cache_root.empty()
? project / ".faset" / "cache"
: config.cache_root).lexically_normal();
const auto engine = fs::absolute(config.engine_root).lexically_normal();
if (native.parent_path() != base ||
(native.filename() != "Debug" && native.filename() != "Release" &&
native.filename() != "RelWithDebInfo"))
throw std::runtime_error("Native build path is outside its configured managed root");
const auto default_base = project / ".faset" / "build";
if ((within(base, project) && base != default_base) || within(project, base) ||
within(base, cache) || within(cache, base) || within(base, engine) ||
within(engine, base))
throw std::runtime_error("Native build root overlaps project, cache or engine files");
refuse_symlink_ancestors(native);
const auto stamp = native_directory / ".faset-toolchain.json";
bool matching = false, owned = false;
if (fs::is_regular_file(stamp) && !fs::is_symlink(stamp))
try {
const auto existing = read_json(stamp);
const auto format = existing.value("format", std::string()) ==
"faset.toolchain-stamp";
const auto version = existing.value("version", 0);
owned = format &&
((version == 2 &&
existing.value("project_root", std::string()) == path_to_utf8(project) &&
existing.value("native_directory", std::string()) == path_to_utf8(native)) ||
(version == 1 && base == default_base));
matching = owned &&
existing.value("toolchain_hash", std::string()) == inputs.toolchain_hash;
} catch (const std::exception&) {
owned = false;
}
if (fs::exists(native_directory) && !owned)
throw std::runtime_error("Refusing to clear an unowned native build directory: " +
path_to_utf8(native));
if (!matching && fs::exists(native_directory))
fs::remove_all(native_directory);
fs::create_directories(native_directory);
atomic_write_json(stamp, {{"format", "faset.toolchain-stamp"},
{"version", 2},
{"project_root", path_to_utf8(project)},
{"native_directory", path_to_utf8(native)},
{"toolchain_hash", inputs.toolchain_hash}});
}
std::string build_package_key(const BuildInputs& inputs, const fs::path& native_directory,
const std::string& configuration, const fs::path& player,
const fs::path& exporter) {
Json files = Json::object();
for (const auto& [name, path] :
{std::pair{"player", player}, std::pair{"exporter", exporter},
std::pair{"cmake_cache", native_directory / "CMakeCache.txt"}}) {
if (!fs::is_regular_file(path) || fs::is_symlink(path))
throw std::runtime_error("Native build artifact is missing: " + path_to_utf8(path));
files[name] = sha256_file(path);
}
const auto shaders = native_directory / "shaders";
if (!fs::is_directory(shaders))
throw std::runtime_error("Native shader directory is missing");
for (const auto& entry : fs::recursive_directory_iterator(shaders)) {
if (entry.is_symlink())
throw std::runtime_error("Native shader is a symlink");
if (entry.is_regular_file())
files[generic_path_to_utf8(entry.path().lexically_relative(native_directory))] =
sha256_file(entry.path());
}
// On Windows, a changed runtime DLL must also invalidate a package hit.
for (const auto& root : {player.parent_path(), native_directory,
native_directory / configuration}) {
if (!fs::is_directory(root))
continue;
for (const auto& entry : fs::directory_iterator(root)) {
auto extension = path_to_utf8(entry.path().extension());
for (auto& character : extension)
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
if (extension != ".dll")
continue;
if (!entry.is_regular_file() || entry.is_symlink())
throw std::runtime_error("Native runtime DLL is not a regular file");
files["dll:" + path_to_utf8(entry.path().filename())] = sha256_file(entry.path());
}
}
return normalized_hash({{"format", "faset.package-key.v1"},
{"inputs", inputs.fingerprint()},
{"configuration", configuration},
{"files", files}});
}
bool validate_build_generation(const fs::path& directory, const std::string& package_key) {
try {
if (!fs::is_directory(directory) || fs::is_symlink(directory))
return false;
const auto manifest_file = directory / "manifest.json";
if (!fs::is_regular_file(manifest_file) || fs::is_symlink(manifest_file))
return false;
const auto manifest = read_json(manifest_file);
if (manifest.at("format") != "faset.build" || manifest.at("version") != 2 ||
manifest.at("package_key") != package_key || !manifest.at("files").is_array())
return false;
std::set<std::string> expected;
for (const auto& record : manifest.at("files")) {
const auto name = record.at("path").get<std::string>();
const auto relative = path_from_utf8(name);
if (relative.empty() || relative.is_absolute() || name == "manifest.json" ||
generic_path_to_utf8(relative) != name)
return false;
for (const auto& component : relative)
if (component == "." || component == "..")
return false;
if (!expected.insert(name).second)
return false;
auto file = directory;
for (const auto& component : relative) {
file /= component;
if (fs::is_symlink(file))
return false;
}
if (!fs::is_regular_file(file) || fs::is_symlink(file) ||
sha256_file(file) != record.at("sha256").get<std::string>() ||
fs::file_size(file) != record.at("size").get<std::uintmax_t>())
return false;
}
for (const auto& entry : fs::recursive_directory_iterator(directory)) {
if (entry.is_symlink())
return false;
if (entry.is_regular_file()) {
const auto name = generic_path_to_utf8(entry.path().lexically_relative(directory));
if (name != "manifest.json" && !expected.contains(name))
return false;
}
}
const auto player_name = manifest.at("player").get<std::string>();
const auto schema_name = manifest.at("schema").get<std::string>();
if (!expected.contains(player_name) || !expected.contains(schema_name) ||
!expected.contains("faset_schema_exporter" + fs::path(player_name).extension().string()))
return false;
for (const auto* shader : {"vertexMain", "fragmentMain", "shadowMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
if (!expected.contains("shaders/" + std::string(shader) + extension))
return false;
const auto schema = read_json(directory / schema_name);
if (schema.at("format") != "faset.schema" || schema.at("version") != 1 ||
schema.at("build_fingerprint") != manifest.at("fingerprint"))
return false;
(void)authoring::gameplay_schemas(schema);
return true;
} catch (const std::exception&) {
return false;
}
}
} // namespace faset::editor
-149
View File
@@ -1,149 +0,0 @@
#include <faset/editor/build_diagnostics.hpp>
#include <faset/core/io.hpp>
#include <algorithm>
#include <cctype>
#include <regex>
#include <sstream>
namespace faset::editor {
namespace {
constexpr std::size_t max_rows = 200;
constexpr std::size_t max_line = 8192;
constexpr std::size_t max_message = 2048;
std::string strip_ansi(std::string_view text) {
std::string clean;
clean.reserve(text.size());
for (std::size_t index = 0; index < text.size(); ++index) {
if (text[index] == '\x1b' && index + 1 < text.size() && text[index + 1] == '[') {
index += 2;
while (index < text.size() && !(text[index] >= '@' && text[index] <= '~'))
++index;
continue;
}
clean += text[index];
}
return clean;
}
std::string normalize_path(std::string path) {
std::replace(path.begin(), path.end(), '\\', '/');
// Compiler output is UTF-8. Constructing a path from a narrow string on
// Windows can decode it through the process code page and lose Unicode.
path = generic_path_to_utf8(path_from_utf8(path).lexically_normal());
while (path.size() > 1 && path.back() == '/')
path.pop_back();
return path;
}
bool has_windows_drive(std::string_view path) {
return path.size() >= 2 && std::isalpha(static_cast<unsigned char>(path[0])) &&
path[1] == ':';
}
std::string lower_ascii(std::string value) {
for (auto& character : value)
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
return value;
}
std::string project_source(std::string raw, const std::filesystem::path& project_root) {
if (raw.starts_with("lua: "))
raw.erase(0, 5);
std::replace(raw.begin(), raw.end(), '\\', '/');
for (const auto& component : path_from_utf8(raw))
if (component == "..")
return {};
const auto path = normalize_path(std::move(raw));
const auto root = normalize_path(path_to_utf8(project_root));
std::string relative;
if (!path.empty() && path[0] != '/' && !has_windows_drive(path))
relative = path;
else {
const auto windows = has_windows_drive(path) && has_windows_drive(root);
const auto comparable_path = windows ? lower_ascii(path) : path;
const auto comparable_root = windows ? lower_ascii(root) : root;
if (comparable_path.size() <= comparable_root.size() ||
!comparable_path.starts_with(comparable_root) ||
comparable_path[comparable_root.size()] != '/')
return {};
relative = path.substr(root.size() + 1);
}
if (!relative.starts_with("Scripts/") || relative.size() <= 8)
return {};
if (relative.find("/../") != std::string::npos || relative.ends_with("/.."))
return {};
return relative;
}
int positive_number(const std::string& text) {
try {
const auto value = std::stoll(text);
return value > 0 && value <= 100000000 ? static_cast<int>(value) : 0;
} catch (const std::exception&) {
return 0;
}
}
} // namespace
Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase,
const std::filesystem::path& project_root) {
static const std::regex msvc(
R"(^(.+)\(([0-9]+)(?:,([0-9]+))?\)\s*:\s*(fatal error|error|warning|note)\s*([A-Za-z]+[0-9]+)?\s*:\s*(.*)$)");
static const std::regex clang_column(
R"(^(.+):([0-9]+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
static const std::regex clang_line(
R"(^(.+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
static const std::regex lua(R"(^(.+\.lua):([0-9]+):\s*(.*)$)");
Json rows = Json::array();
std::istringstream stream{std::string(raw_log)};
std::string raw;
while (rows.size() < max_rows && std::getline(stream, raw)) {
if (raw.size() > max_line)
raw.resize(max_line);
auto line = strip_ansi(raw);
if (!line.empty() && line.back() == '\r')
line.pop_back();
std::smatch match;
std::string file, message, severity, code;
int row = 0, column = 0;
bool has_column = false;
if (std::regex_match(line, match, msvc) ||
std::regex_match(line, match, clang_column)) {
file = match[1].str();
row = positive_number(match[2].str());
has_column = !match[3].str().empty();
column = positive_number(match[3].str());
severity = match[4].str();
code = match[5].str();
message = match[6].str();
} else if (std::regex_match(line, match, clang_line)) {
file = match[1].str();
row = positive_number(match[2].str());
severity = match[3].str();
code = match[4].str();
message = match[5].str();
} else if (std::regex_match(line, match, lua)) {
file = match[1].str();
row = positive_number(match[2].str());
severity = "error";
message = match[3].str();
} else
continue;
if (row == 0 || (has_column && column == 0))
continue;
if (severity == "fatal error")
severity = "error";
if (message.size() > max_message)
message.resize(max_message);
Json diagnostic{{"severity", severity},
{"phase", std::string(phase)},
{"message", message},
{"line", row}};
if (column > 0)
diagnostic["column"] = column;
if (!code.empty())
diagnostic["code"] = code;
if (auto relative = project_source(file, project_root); !relative.empty())
diagnostic["file"] = std::move(relative);
rows.push_back(std::move(diagnostic));
}
return rows;
}
} // namespace faset::editor
+36 -345
View File
@@ -1,4 +1,3 @@
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
@@ -7,12 +6,9 @@
#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>
@@ -93,107 +89,11 @@ 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}, {"diagnostics", diagnostics}, {"result", result}};
{"error", error}, {"result", result}};
}
void write_cooked_scene(const fs::path& path, const Json& scene) {
validate_scene(scene);
@@ -298,13 +198,6 @@ 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();
@@ -312,35 +205,6 @@ 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();
@@ -350,29 +214,14 @@ 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) {
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"}});
if (poll.exit_code.value_or(1) != 0)
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));
@@ -391,23 +240,19 @@ 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 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 auto cpp = config.project_root / "Scripts" / "Gameplay.cpp";
const auto hpp = config.project_root / "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");
ensure_native_toolchain_stamp(config, native_directory, inputs);
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);
std::vector<std::string> arguments = {config.cmake,
"-S",
path_to_utf8(config.engine_root),
@@ -422,100 +267,37 @@ struct BuildService::Impl {
"-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
path_to_utf8(staged_scripts)};
if (!configured_cmake_value(config, "CMAKE_C_COMPILER")) {
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) {
#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
}
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.insert(arguments.end(), config.configure_arguments.begin(),
config.configure_arguments.end());
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);
const auto compiled = std::chrono::steady_clock::now();
checkpoint(job, "Exporting gameplay schema", .58);
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);
@@ -540,7 +322,10 @@ 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);
const auto fingerprint = package_key;
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);
schema["build_fingerprint"] = fingerprint;
if (job.lua.enabled())
schema["lua_fingerprint"] = job.lua.fingerprint;
@@ -556,39 +341,36 @@ 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", 2},
{"version", 1},
{"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"},
{"files", files}};
{"schema", "schema.json"}};
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");
source_unchanged();
if (!validate_build_generation(staging, package_key))
throw std::runtime_error("Build generation failed final integrity validation");
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");
fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
return result_for(job.status.id, fingerprint, false);
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}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
@@ -1003,97 +785,6 @@ 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");
}
+2 -6
View File
@@ -103,13 +103,9 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
add("faset_document_save",
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
object_schema({{"document", text}, {"path", text},
{"expected_revision", integer}}, {"document"}), [&](const Json& args) {
std::optional<std::uint64_t> expected;
if (args.contains("expected_revision"))
expected = args.at("expected_revision").get<std::uint64_t>();
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
return authoring_.save(args.at("document"),
path_from_utf8(args.value("path", std::string())), expected);
path_from_utf8(args.value("path", std::string())));
});
add(
"faset_schema",
+6 -155
View File
@@ -168,7 +168,7 @@ struct EditorUI::Impl {
Json instance_selection = Json::array(), template_conflicts = Json::array();
std::string document, selected, source_file, asset_filter,
active_bottom = "assets", menu, command_name = "faset_documents", status = "Ready",
gizmo_mode = "Move", expanded_job_log;
gizmo_mode = "Move";
Json current, resolved, files = Json::array(), assets = Json::array(), schemas = Json::object(),
recovery = Json::array();
std::uint64_t shown_revision = std::numeric_limits<std::uint64_t>::max();
@@ -186,7 +186,6 @@ struct EditorUI::Impl {
bool project_settings_open = false;
Json project_settings_state;
int project_settings_dimension = 3;
bool project_settings_autosave = true;
std::string project_settings_error;
bool project_switch_enabled = true, project_switch_requested = false,
project_switch_warning = false;
@@ -602,20 +601,6 @@ struct EditorUI::Impl {
schema_status.visible = false;
schema_status.enabled = false;
schema_status.tooltip = "Choose Build to refresh gameplay fields in Inspector";
auto& autosave_status = statusrow.add(Kind::Label, "autosave-status", "Saved");
autosave_status.layout.width = 160;
autosave_status.enabled = false;
auto& autosave_action = button(statusrow, "autosave-save-as", "Save As...", [this] {
const auto path = current.value("path", std::string());
const auto directory = path.empty() ? std::string("Scenes/")
: path.substr(0, path.find_last_of('/') + 1);
ui.update_text("save-path",
directory + "Recovered-" + new_id().substr(0, 8) + ".scene.json",
true);
menu = "File";
}, 100);
autosave_action.visible = false;
autosave_action.tooltip = "Save this scene to a new project-relative path";
label(statusrow, "renderer-status", "Vulkan", 225);
statusrow.find("renderer-status")->enabled = false;
build_overlays();
@@ -807,20 +792,12 @@ struct EditorUI::Impl {
}
label(project, "project-settings-start-label", "Start scene (saved project-relative path)");
project.add(Kind::TextField, "project-settings-start");
auto& autosave = project.add(Kind::Checkbox, "project-settings-autosave",
"Autosave named scenes after 2 seconds idle");
autosave.layout.height = 30;
autosave.checked = true;
autosave.on_commit = [this](Widget& widget) {
project_settings_autosave = widget.checked;
};
autosave.tooltip = "Unsaved scenes remain in recovery until you choose Save As";
auto& scenes = project.add(Kind::Column, "project-settings-scenes");
scenes.layout.height = 104;
scenes.layout.scroll = true;
scenes.layout.gap = 1;
label(project, "project-settings-note",
"Autosave applies now; other settings on next open. Scene Undo is unchanged.");
"Applies on next project open. Scene Undo is unchanged.");
auto& project_actions = project.add(Kind::Row, "project-settings-actions");
project_actions.layout.height = 32;
button(
@@ -1281,42 +1258,6 @@ struct EditorUI::Impl {
(current.value("dirty", false) ? " *" : "");
ui.find("project-title")->tooltip = project_name + " / " + scene_name;
ui.find("status")->text = status;
const auto autosave = call("faset_autosave_status");
std::string save_state = "saved", save_error;
if (!autosave.is_null()) {
if (!autosave.value("enabled", true))
save_state = "disabled";
for (const auto& entry : autosave.at("documents"))
if (entry.at("id") == document) {
save_state = entry.at("state").get<std::string>();
save_error = entry.value("error", std::string());
break;
}
if (!autosave.value("enabled", true) && save_state == "saved")
save_state = "disabled";
}
auto& save_label = *ui.find("autosave-status");
save_label.text = save_state == "pending" ? "Pending autosave"
: save_state == "saving" ? "Saving"
: save_state == "conflict" ? "Save conflict"
: save_state == "failed" ? "Save failed"
: save_state == "save_as_required" ? "Save As required"
: save_state == "disabled" ? "Autosave off"
: "Saved";
save_label.tooltip = save_state == "conflict"
? "Scene file changed outside Faset. Use Save As to keep both "
"versions, then compare or reload. " + save_error
: save_state == "failed"
? "Scene save failed. Your recovery journal remains available. "
"Use Save As or correct the error. " + save_error
: save_state == "save_as_required"
? "Choose Save As; unnamed scenes remain in recovery only"
: save_state == "disabled"
? "Autosave is off in Project settings; use Save manually"
: "Scene autosave status";
ui.find("autosave-save-as")->visible =
save_state == "conflict" || save_state == "failed" ||
save_state == "save_as_required";
ui.find("renderer-status")->text =
"Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects";
}
@@ -2102,45 +2043,6 @@ struct EditorUI::Impl {
trim_children(conflict_list, conflict_keep);
auto& console = *ui.find("console-items");
std::set<std::string> keep;
std::size_t shown_diagnostics{};
for (const auto& job : all_jobs) {
const auto id = job.at("id").get<std::string>();
const auto diagnostics = job.value("diagnostics", Json::array());
if (!diagnostics.is_array())
continue;
for (std::size_t index = 0; index < diagnostics.size() && shown_diagnostics < 60;
++index, ++shown_diagnostics) {
const auto& diagnostic = diagnostics[index];
const auto row_id = "diagnostic-" + id + "-" + std::to_string(index);
auto& row = console.add(Kind::Row, row_id);
row.layout.height = 27;
row.layout.gap = 8;
keep.insert(row_id);
const auto severity = diagnostic.value("severity", std::string("error"));
label(row, row_id + "-severity", severity, 68);
auto message = diagnostic.value("message", std::string());
auto& detail = row.add(Kind::Label, row_id + "-message", message);
detail.layout.flex = 1;
detail.tooltip = diagnostic.value("phase", std::string()) + ": " + message;
const auto file = diagnostic.value("file", std::string());
const auto line = diagnostic.value("line", 1);
const auto column = diagnostic.value("column", 1);
const auto location = file.empty()
? std::string("—")
: file + ":" + std::to_string(line) + ":" +
std::to_string(column);
label(row, row_id + "-location", location, 235);
auto& open = button(
row, row_id + "-open", "Open source",
[this, file, line, column] {
call("faset_source_open",
{{"path", file}, {"line", line}, {"column", column}});
},
115);
open.enabled = !file.empty();
open.appearance = ui::Appearance::Quiet;
}
}
const auto& logs = session.logs();
for (std::size_t i = logs.size() > 150 ? logs.size() - 150 : 0; i < logs.size(); ++i) {
std::string line = logs[i];
@@ -2163,25 +2065,11 @@ struct EditorUI::Impl {
row.layout.height = 28;
keep.insert(row.id);
const auto state = job.value("state", std::string());
const auto result = job.value("result", Json::object());
std::string reuse;
if (result.is_object() && result.value("schema_cache_hit", false))
reuse = " · verified reuse";
if (result.is_object() && result.contains("phase_times_ms") &&
result.at("phase_times_ms").is_object())
reuse += " · " +
std::to_string(result.at("phase_times_ms").value("total", 0)) + " ms";
auto& text = row.add(Kind::Label, "job-text-" + id,
job.value("kind", std::string("Job")) + " · " + state + " · " +
job.value("stage", std::string()) + " " +
std::to_string(int(job.value("progress", 0.0) * 100)) + "%" +
reuse);
std::to_string(int(job.value("progress", 0.0) * 100)) + "%");
text.layout.flex = 1;
auto& log_button = button(
row, "job-log-toggle-" + id,
expanded_job_log == id ? "Hide log" : "View log",
[this, id] { expanded_job_log = expanded_job_log == id ? "" : id; }, 86);
log_button.appearance = ui::Appearance::Quiet;
auto& cancel = button(
row, "job-cancel-" + id, "Cancel",
[this, id] { call("faset_job_cancel", {{"id", id}}); }, 72);
@@ -2190,38 +2078,6 @@ struct EditorUI::Impl {
row, "job-review-" + id, "Review removals", [this] { active_bottom = "conflicts"; },
135);
review.visible = job.value("kind", std::string()) == "import" && state == "conflict";
if (expanded_job_log == id) {
const auto panel_id = "job-log-panel-" + id;
auto& panel = jobs.add(Kind::Column, panel_id);
panel.layout.height = 170;
panel.layout.scroll = true;
panel.layout.gap = 1;
keep.insert(panel_id);
std::set<std::string> log_keep;
const auto raw = job.value("log", std::string());
auto& copy = button(panel, panel_id + "-copy", "Copy full log",
[this, raw] { renderer.set_clipboard(raw); }, 110);
copy.appearance = ui::Appearance::Quiet;
log_keep.insert(copy.id);
std::vector<std::string> lines;
std::size_t begin{};
while (begin < raw.size()) {
const auto end = raw.find('\n', begin);
lines.push_back(raw.substr(begin, end == std::string::npos
? std::string::npos
: end - begin));
if (end == std::string::npos)
break;
begin = end + 1;
}
const auto first = lines.size() > 100 ? lines.size() - 100 : 0;
for (std::size_t index = first; index < lines.size(); ++index) {
const auto line_id = panel_id + "-line-" + std::to_string(index);
label(panel, line_id, lines[index].substr(0, 600));
log_keep.insert(line_id);
}
trim_children(panel, log_keep);
}
}
if (keep.empty()) {
label(jobs, "jobs-empty", "No active import, build or export jobs.");
@@ -2238,7 +2094,7 @@ struct EditorUI::Impl {
refresh_project_settings();
const bool modal = palette || !recovery.empty() || simulation_open ||
project_switch_warning || project_settings_open;
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel", "statusbar"})
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel"})
ui.find(id)->enabled = !modal;
const bool file = menu == "File" || menu == "Faset",
edit = menu == "Edit" || menu == "Scene",
@@ -2293,8 +2149,6 @@ struct EditorUI::Impl {
project_settings_state = result;
const auto& settings = result.at("settings");
project_settings_dimension = settings.value("dimension", 3);
project_settings_autosave = settings.at("editor").at("autosave").get<bool>();
ui.find("project-settings-autosave")->checked = project_settings_autosave;
ui.update_text("project-settings-name", settings.value("name", std::string()), true);
ui.update_text("project-settings-start", settings.value("start_scene", std::string()),
true);
@@ -2361,9 +2215,7 @@ struct EditorUI::Impl {
project_settings_error = "Enter a project name.";
return;
}
Json changes = {{"name", name},
{"dimension", project_settings_dimension},
{"editor", {{"autosave", project_settings_autosave}}}};
Json changes = {{"name", name}, {"dimension", project_settings_dimension}};
if (!start.empty() ||
!project_settings_state.at("settings").value("start_scene", std::string()).empty())
changes["start_scene"] = start;
@@ -2377,7 +2229,7 @@ struct EditorUI::Impl {
project_settings_state = result;
project_settings_open = false;
project_settings_error.clear();
status = "Project settings saved; autosave preference applies now";
status = "Project settings saved for the next project open";
}
void refresh_project_settings() {
auto* panel = ui.find("project-settings-panel");
@@ -2388,7 +2240,6 @@ struct EditorUI::Impl {
panel->layout.y = std::max(0.f, (logical_height() - panel->layout.height) * .5f);
ui.find("project-settings-2d")->selected = project_settings_dimension == 2;
ui.find("project-settings-3d")->selected = project_settings_dimension == 3;
ui.find("project-settings-autosave")->checked = project_settings_autosave;
ui.find("project-settings-error")->text = project_settings_error;
for (auto& choice : ui.find("project-settings-scenes")->children)
if (choice->kind == Kind::TreeRow)
+93 -184
View File
@@ -2,7 +2,6 @@
#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>
@@ -23,52 +22,6 @@ BuildConfig build_config(const SessionConfig& config) {
result.cache_root = config.project_root / ".faset/cache";
return result;
}
struct ProjectState {
Json value;
std::string revision;
};
ProjectState read_project_state(const std::filesystem::path& project_root) {
const auto path = project_root / "project.faset.json";
if (std::filesystem::exists(path)) {
auto value = read_json(path);
// The revision represents persisted content, including whether a
// default-valued setting was explicitly written by another client.
const auto revision = sha256(value.dump());
require(value.is_object() && value.value("format", "") == "faset.project" &&
value.value("version", 0) == 1,
"project.version", "Unsupported project format or version");
require(value.contains("name") && value.at("name").is_string() &&
!value.at("name").get<std::string>().empty(),
"project.name", "Project name must be a nonempty string");
const auto dimension = value.value("dimension", 3);
require(dimension == 2 || dimension == 3, "project.dimension",
"Project dimension must be 2 or 3");
if (value.contains("start_scene")) {
require(value.at("start_scene").is_string(), "project.start_scene",
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(project_root, path_from_utf8(scene));
}
if (value.contains("editor"))
require(value.at("editor").is_object(), "project.editor",
"Project editor settings must be an object");
else
value["editor"] = Json::object();
if (value["editor"].contains("autosave"))
require(value["editor"]["autosave"].is_boolean(), "project.autosave",
"Project autosave setting must be true or false");
else
value["editor"]["autosave"] = true;
return {std::move(value), revision};
}
Json value = {{"format", "faset.project"},
{"version", 1},
{"name", path_to_utf8(project_root.filename())},
{"dimension", 3},
{"editor", {{"autosave", true}}}};
return {value, sha256(value.dump())};
}
Json resolved_or_throw(Commands& commands, const std::string& id) {
const auto resolved = commands.resolved_scene(id);
if (!resolved.at("conflicts").empty())
@@ -98,13 +51,7 @@ struct Session::ImportTask {
};
Session::Session(SessionConfig config)
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)),
autosave_([this](const std::string& document, std::uint64_t revision) {
return authoring_.save(document, {}, revision);
}) {
autosave_enabled_ = project().at("editor").at("autosave").get<bool>();
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
autosave_enabled_);
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
register_commands();
plugins_ = std::make_unique<PluginManager>(
commands_, [this](std::string message) { log(std::move(message)); });
@@ -135,16 +82,36 @@ void Session::log(std::string value) {
logs_.erase(logs_.begin(), logs_.begin() + 100);
}
Json Session::project() const {
return read_project_state(config_.project_root).value;
const auto path = config_.project_root / "project.faset.json";
if (std::filesystem::exists(path)) {
const auto value = read_json(path);
require(value.is_object() && value.value("format", "") == "faset.project" &&
value.value("version", 0) == 1,
"project.version", "Unsupported project format or version");
require(value.contains("name") && value.at("name").is_string() &&
!value.at("name").get<std::string>().empty(),
"project.name", "Project name must be a nonempty string");
const auto dimension = value.value("dimension", 3);
require(dimension == 2 || dimension == 3, "project.dimension",
"Project dimension must be 2 or 3");
if (value.contains("start_scene")) {
require(value.at("start_scene").is_string(), "project.start_scene",
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(config_.project_root, path_from_utf8(scene));
}
return value;
}
return {{"format", "faset.project"},
{"version", 1},
{"name", path_to_utf8(config_.project_root.filename())},
{"dimension", 3}};
}
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";
@@ -165,7 +132,20 @@ Json Session::assets_list() const {
}
std::string Session::source_signature() const {
const auto lua = scripting::loadLuaProject(config_.project_root);
return gameplay_source_hash(config_.project_root, lua);
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);
}
Json Session::schema_status() const {
try {
@@ -325,19 +305,12 @@ void Session::poll() {
if (status == "failed" || status == "conflict")
log("Asset import " + status + ": " + value.value("error", std::string()));
}
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
autosave_enabled_);
}
void Session::register_commands() {
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
auto schema = [](Json properties, Json required = Json::array()) {
return Commands::object_schema(std::move(properties), std::move(required));
};
commands_.add("faset_autosave_status",
"Read revision-aware scene autosave state. Unnamed scenes remain in recovery "
"until explicitly saved with a path.",
schema(Json::object()),
[this](const Json&) { return autosave_.status(); }, true);
commands_.add(
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
schema(Json::object()),
@@ -371,25 +344,23 @@ void Session::register_commands() {
"faset_project_settings_get", "Read project settings and their content revision.",
schema(Json::object()),
[&](const Json&) {
const auto state = read_project_state(config_.project_root);
return Json{{"settings", state.value}, {"revision", state.revision}};
const auto value = project();
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
},
true);
commands_.add(
"faset_project_settings_set",
"Save project name, initial scene dimension, start scene or editor.autosave with an "
"expected content revision. Autosave applies immediately; other settings apply on the "
"next project open without changing scene Undo history.",
"Save project name, initial scene dimension or start scene with an expected content "
"revision. Applies on the next project open; does not change the active scene or its Undo "
"history.",
schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}),
[&](const Json& args) {
auto state = read_project_state(config_.project_root);
auto value = std::move(state.value);
require(args.at("revision") == state.revision, "revision.conflict",
auto value = project();
require(args.at("revision") == sha256(value.dump()), "revision.conflict",
"Project settings changed; reload them before saving");
const auto& changes = args.at("settings");
for (const auto& [key, field] : changes.items()) {
require(key == "name" || key == "dimension" || key == "start_scene" ||
key == "editor",
require(key == "name" || key == "dimension" || key == "start_scene",
"project.setting", "Unknown editable project setting: " + key);
if (key == "name")
require(field.is_string() && !field.get<std::string>().empty(), "project.name",
@@ -397,15 +368,7 @@ void Session::register_commands() {
else if (key == "dimension")
require(field.is_number_integer() && (field == 2 || field == 3),
"project.dimension", "Initial scene dimension must be 2 or 3");
else if (key == "editor") {
require(field.is_object() && field.size() == 1 &&
field.contains("autosave"),
"project.setting", "Only editor.autosave can be changed here");
require(field.at("autosave").is_boolean(), "project.autosave",
"Autosave must be enabled or disabled");
value["editor"]["autosave"] = field.at("autosave");
continue;
} else {
else {
require(field.is_string() && !field.get<std::string>().empty(),
"project.start_scene", "Choose a saved scene inside the project");
const auto file = project_path(config_.project_root,
@@ -423,10 +386,7 @@ void Session::register_commands() {
if (!value.contains("id"))
value["id"] = new_id();
atomic_write_json(config_.project_root / "project.faset.json", value);
autosave_enabled_ = value.at("editor").at("autosave").get<bool>();
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
autosave_enabled_);
log("Project settings saved; autosave preference applies immediately");
log("Project settings saved; changes apply on next project open");
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
});
commands_.add(
@@ -554,102 +514,51 @@ void Session::register_commands() {
{"configuration_created", create_configuration},
{"scripts_configuration_created", create_scripts_configuration}};
});
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;
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));
}
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 {
require(!arguments.front().empty(), "lua.editor", "Choose an editor executable");
if (!has_file)
arguments.push_back(path_to_utf8(file));
launch_detached(arguments, config_.project_root);
} 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);
});
log("Opened Lua source in external editor: " + args.at("path").get<std::string>());
return Json{{"opened", args.at("path")}};
});
commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.",
+129 -5
View File
@@ -61,6 +61,16 @@ render::Vec3 direction(const render::Mat4& m, render::Vec3 p) {
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2]};
}
render::Vec3 normalized(render::Vec3 value, const std::string& entityId,
std::string_view field) {
const auto length = std::hypot(value[0], value[1], value[2]);
if (!std::isfinite(length) || length < 1e-6f)
throw std::invalid_argument("Light on entity " + entityId + " has invalid " +
std::string(field));
for (auto& axis : value)
axis /= length;
return value;
}
std::pair<std::string, std::string> reference(const std::string& ref) {
const auto hash = ref.find('#');
return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)};
@@ -263,8 +273,12 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
const auto id = entity.at("id").get<std::string>();
if (!byId.emplace(id, &entity).second)
throw std::invalid_argument("Duplicate scene ID");
if (!entity.contains("components") && entity.contains("light"))
out.authored_lights_present = true;
for (const auto& component : entity.value("components", Json::array())) {
const auto type = component.at("type").get<std::string>();
if (type == "faset.light")
out.authored_lights_present = true;
if (component.value("version", 1) != 1 &&
(type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" ||
type == "faset.camera" || type == "faset.light"))
@@ -301,8 +315,15 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
render::Vec3 cameraUp{0, 1, 0};
bool foundCamera = false;
std::vector<std::pair<int, render::Sprite>> sprites;
std::vector<render::SunLight> directionalLights;
for (const auto& entity : entities) {
const auto model = world(world, entity);
const auto id = entity.at("id").get<std::string>();
render::Mat4 model;
try {
model = world(world, entity);
} catch (const std::exception& error) {
throw std::invalid_argument("Entity " + id + " transform: " + error.what());
}
if (auto fields = properties(entity, "camera");
!fields.is_null() && !camera.overrideSceneCamera && !foundCamera) {
camera.eye = point(model, {0, 0, 0});
@@ -314,8 +335,97 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
foundCamera = true;
camera_id = entity.at("id").get<std::string>();
}
if (auto fields = properties(entity, "light"); !fields.is_null())
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
if (auto fields = properties(entity, "light"); !fields.is_null()) {
auto invalid = [&](std::string_view field) -> void {
throw std::invalid_argument("Light on entity " + id + " has invalid " +
std::string(field));
};
for (const auto entry : model)
if (!std::isfinite(entry))
invalid("transform");
auto number = [&](const char* field, float fallback) {
if (!fields.contains(field))
return fallback;
const auto& value = fields.at(field);
if (!value.is_number())
invalid(field);
const auto decimal = value.get<double>();
if (!std::isfinite(decimal) ||
std::abs(decimal) > std::numeric_limits<float>::max())
invalid(field);
return static_cast<float>(decimal);
};
auto boolean = [&](const char* field, bool fallback) {
if (!fields.contains(field))
return fallback;
if (!fields.at(field).is_boolean())
invalid(field);
return fields.at(field).get<bool>();
};
const auto enabled = boolean("enabled", true);
if (enabled) {
if (fields.contains("kind") && !fields.at("kind").is_string())
invalid("kind");
const auto kind = fields.value("kind", std::string("directional"));
if (kind != "directional" && kind != "point" && kind != "spot")
invalid("kind");
render::Color color;
try {
color = vec<4>(fields, "color", {1, 1, 1, 1});
} catch (const std::exception&) {
invalid("color");
}
for (const auto channel : color)
if (channel < 0)
invalid("color");
const auto intensity = number("intensity", 1);
if (intensity < 0)
invalid("intensity");
const auto castsShadow = boolean("casts_shadow", true);
const auto stableId = id;
if (kind == "directional") {
directionalLights.push_back({stableId,
normalized(direction(model, {-0.5f, -1, -0.3f}), id,
"direction"),
color, intensity, castsShadow});
} else {
render::LocalLight local;
local.kind = kind == "point" ? render::LocalLight::Kind::Point
: render::LocalLight::Kind::Spot;
local.stable_id = stableId;
local.position = point(model, {0, 0, 0});
for (const auto coordinate : local.position)
if (!std::isfinite(coordinate))
invalid("position");
if (local.kind == render::LocalLight::Kind::Spot)
local.direction = normalized(direction(model, {0, 0, -1}), id,
"direction");
local.color = color;
local.intensity = intensity;
local.range = number("range", 10);
if (local.range <= 0)
invalid("range");
local.inner_angle = number("inner_angle", 0.35f);
local.outer_angle = number("outer_angle", 0.7f);
if (local.kind == render::LocalLight::Kind::Spot &&
(local.inner_angle < 0 || local.inner_angle > local.outer_angle ||
local.outer_angle <= 0 ||
local.outer_angle >= std::numbers::pi_v<float> / 2))
invalid("inner_angle/outer_angle");
local.casts_shadow = castsShadow;
if (fields.contains("shadow_priority")) {
if (!fields.at("shadow_priority").is_number_integer())
invalid("shadow_priority");
const auto priority = fields.at("shadow_priority").get<double>();
if (priority < std::numeric_limits<int>::min() ||
priority > std::numeric_limits<int>::max())
invalid("shadow_priority");
local.shadow_priority = fields.at("shadow_priority").get<int>();
}
out.local_lights.push_back(std::move(local));
}
}
}
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
render::Sprite sprite;
sprite.layer = fields.value("layer", 0);
@@ -372,6 +482,18 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
[](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& pair : sprites)
out.sprites.push_back(std::move(pair.second));
std::sort(directionalLights.begin(), directionalLights.end(),
[](const auto& a, const auto& b) { return a.stable_id < b.stable_id; });
std::sort(out.local_lights.begin(), out.local_lights.end(),
[](const auto& a, const auto& b) { return a.stable_id < b.stable_id; });
if (!directionalLights.empty()) {
out.sun = directionalLights.front();
out.light_direction = out.sun->direction;
if (directionalLights.size() > 1)
impl_->messages.push_back("warning: multiple enabled directional lights; using " +
out.sun->stable_id + " and ignoring " +
std::to_string(directionalLights.size() - 1) + " others");
}
if (dimension == 2) {
const float height = camera.orthographicHeight;
if (!std::isfinite(height) || height <= 0)
@@ -408,8 +530,10 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
out.projection = render::perspective(
camera.verticalFovDegrees * std::numbers::pi_v<float> / 180, aspect,
camera.nearPlane, camera.farPlane);
out.view_projection = render::multiply(
out.projection, render::look_at(camera.eye, camera.target, cameraUp));
const auto view = render::look_at(camera.eye, camera.target, cameraUp);
out.view_projection = render::multiply(out.projection, view);
out.camera_frustum = render::CameraFrustum{view, out.projection, camera.nearPlane,
camera.farPlane, true};
}
std::sort(impl_->messages.begin(), impl_->messages.end());
impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()),
+392
View File
@@ -0,0 +1,392 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <unordered_set>
namespace faset::render {
namespace {
Vec3 add(Vec3 a, Vec3 b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; }
Vec3 subtract(Vec3 a, Vec3 b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; }
Vec3 scale(Vec3 a, float factor) { return {a[0] * factor, a[1] * factor, a[2] * factor}; }
float dot(Vec3 a, Vec3 b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
float length(Vec3 a) { return std::sqrt(dot(a, a)); }
Vec3 unit(Vec3 a) {
const float magnitude = length(a);
if (!std::isfinite(magnitude) || magnitude < 1e-6f)
throw std::invalid_argument("Shadow light direction must be finite and nonzero");
return scale(a, 1.f / magnitude);
}
Vec3 project(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14]};
}
std::array<float, 4> clip(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14],
matrix[3] * value[0] + matrix[7] * value[1] + matrix[11] * value[2] + matrix[15]};
}
std::array<Vec3, 8> corners(const Bounds& bounds) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i)
result[i] = {i & 1 ? bounds.max[0] : bounds.min[0],
i & 2 ? bounds.max[1] : bounds.min[1],
i & 4 ? bounds.max[2] : bounds.min[2]};
return result;
}
Vec3 camera_to_world(const Mat4& view, Vec3 camera) {
// CameraFrustum::view is an unscaled, orthonormal look_at matrix.
return {view[0] * (camera[0] - view[12]) + view[1] * (camera[1] - view[13]) +
view[2] * (camera[2] - view[14]),
view[4] * (camera[0] - view[12]) + view[5] * (camera[1] - view[13]) +
view[6] * (camera[2] - view[14]),
view[8] * (camera[0] - view[12]) + view[9] * (camera[1] - view[13]) +
view[10] * (camera[2] - view[14])};
}
std::array<Vec3, 8> frustum_slice(const CameraFrustum& camera, float near_distance,
float far_distance) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i) {
const float distance = i & 4 ? far_distance : near_distance;
const float x = i & 1 ? 1.f : -1.f;
const float y = i & 2 ? 1.f : -1.f;
Vec3 local{};
if (camera.perspective)
local = {x * distance / camera.projection[0],
y * distance / camera.projection[5], -distance};
else
local = {(x - camera.projection[12]) / camera.projection[0],
(y - camera.projection[13]) / camera.projection[5], -distance};
result[i] = camera_to_world(camera.view, local);
}
return result;
}
bool overlaps_xy(const Bounds& bounds, const Mat4& light_view, float left, float right,
float bottom, float top) {
float min_x = std::numeric_limits<float>::infinity();
float max_x = -min_x, min_y = min_x, max_y = -min_x;
for (const auto point : corners(bounds)) {
const auto light = project(light_view, point);
min_x = std::min(min_x, light[0]);
max_x = std::max(max_x, light[0]);
min_y = std::min(min_y, light[1]);
max_y = std::max(max_y, light[1]);
}
return max_x >= left && min_x <= right && max_y >= bottom && min_y <= top;
}
bool intersects_frustum(const Bounds& bounds, const Mat4& view_projection) {
std::array<unsigned, 7> rejected{};
for (const auto point : corners(bounds)) {
const auto p = clip(view_projection, point);
if (!std::all_of(p.begin(), p.end(), [](float value) { return std::isfinite(value); }))
return true; // Invalid projection fails open so no caster is lost silently.
rejected[0] += p[0] < -p[3];
rejected[1] += p[0] > p[3];
rejected[2] += p[1] < -p[3];
rejected[3] += p[1] > p[3];
rejected[4] += p[2] < 0;
rejected[5] += p[2] > p[3];
rejected[6] += p[3] <= 0;
}
return std::none_of(rejected.begin(), rejected.end(), [](unsigned count) { return count == 8; });
}
void tile(ShadowView& view, std::uint32_t atlas_size, std::uint32_t tiles_across,
std::uint32_t index) {
constexpr std::uint32_t guard = 2;
const auto size = atlas_size / tiles_across;
view.tile_index = index;
view.tile_origin_x = (index % tiles_across) * size;
view.tile_origin_y = (index / tiles_across) * size;
view.tile_size = size;
view.usable_size = size - 2 * guard;
const auto reciprocal = 1.f / float(atlas_size);
view.atlas_scale_offset = {float(view.usable_size) * reciprocal,
float(view.usable_size) * reciprocal,
float(view.tile_origin_x + guard) * reciprocal,
float(view.tile_origin_y + guard) * reciprocal};
view.guarded_clamp = {(float(view.tile_origin_x + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_y + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_x + size - guard) - 1.5f) * reciprocal,
(float(view.tile_origin_y + size - guard) - 1.5f) * reciprocal};
}
std::vector<std::uint32_t> visible_casters(const Mat4& view_projection,
std::span<const ShadowCasterBounds> casters) {
std::vector<std::uint32_t> result;
for (const auto& caster : casters)
if (intersects_frustum(caster.world, view_projection))
result.push_back(caster.draw_index);
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
bool supported_atlas(std::uint32_t size, bool available) {
return available && (size == 2048 || size == 1024);
}
void validate_local(const LocalLight& light) {
const auto invalid = [&](const char* field) {
throw std::invalid_argument("Local light " + light.stable_id + " has invalid " + field);
};
if (light.stable_id.empty())
invalid("stable_id");
if (!std::all_of(light.position.begin(), light.position.end(),
[](float v) { return std::isfinite(v); }))
invalid("position");
if (!std::all_of(light.color.begin(), light.color.end(),
[](float v) { return std::isfinite(v) && v >= 0; }))
invalid("color");
if (!std::isfinite(light.intensity) || light.intensity < 0)
invalid("intensity");
if (!std::isfinite(light.range) || light.range <= 0)
invalid("range");
if (light.kind == LocalLight::Kind::Spot) {
if (!std::isfinite(light.inner_angle) || !std::isfinite(light.outer_angle) ||
light.inner_angle < 0 || light.inner_angle > light.outer_angle ||
light.outer_angle >= std::numbers::pi_v<float> / 2 || light.outer_angle <= 0)
invalid("inner_angle/outer_angle");
if (!std::all_of(light.direction.begin(), light.direction.end(),
[](float v) { return std::isfinite(v); }) ||
length(light.direction) < 1e-6f)
invalid("direction");
}
}
float projected_influence(const LocalLight& light, const Snapshot& frame) {
const auto distance = length(subtract(light.position, frame.eye));
const auto projection_scale =
std::max(std::abs(frame.projection[0]), std::abs(frame.projection[5]));
return light.range * projection_scale / std::max(distance, .1f);
}
ShadowView sun_view(const Snapshot& frame, const SunLight& sun,
std::span<const ShadowCasterBounds> casters, float split_near,
float split_far, std::uint32_t index, std::uint32_t atlas_size) {
ShadowView result;
result.kind = ShadowView::Kind::Sun;
result.light_id = sun.stable_id;
result.face_index = index;
result.split_near = split_near;
result.split_far = split_far;
tile(result, atlas_size, 2, index);
const auto direction = unit(sun.direction);
const auto up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto light_origin_view = look_at({0, 0, 0}, direction, up);
if (!frame.camera_frustum) {
const auto light_eye = scale(direction, -30);
result.view_projection = multiply(orthographic(-20, 20, -20, 20, .1f, 80),
look_at(light_eye, {0, 0, 0}, up));
result.caster_indices = visible_casters(result.view_projection, casters);
return result;
}
const auto receivers = frustum_slice(*frame.camera_frustum, split_near, split_far);
Vec3 center{};
for (const auto corner : receivers)
center = add(center, scale(corner, 1.f / 8));
float radius{};
for (const auto corner : receivers)
radius = std::max(radius, length(subtract(corner, center)));
radius = std::max(.25f, std::ceil(radius * 16.f) / 16.f);
const auto center_light = project(light_origin_view, center);
const auto texel = (2 * radius) / float(result.usable_size);
result.snapped_center_x = std::round(center_light[0] / texel) * texel;
result.snapped_center_y = std::round(center_light[1] / texel) * texel;
const float left = result.snapped_center_x - radius;
const float right = result.snapped_center_x + radius;
const float bottom = result.snapped_center_y - radius;
const float top = result.snapped_center_y + radius;
float nearest_ray = std::numeric_limits<float>::infinity();
float furthest_ray = -nearest_ray;
for (const auto corner : receivers) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
for (const auto& caster : casters) {
if (!overlaps_xy(caster.world, light_origin_view, left, right, bottom, top))
continue;
result.caster_indices.push_back(caster.draw_index);
for (const auto corner : corners(caster.world)) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
}
std::sort(result.caster_indices.begin(), result.caster_indices.end());
result.caster_indices.erase(
std::unique(result.caster_indices.begin(), result.caster_indices.end()),
result.caster_indices.end());
const auto light_eye = scale(direction, nearest_ray - 1.f);
const auto view = look_at(light_eye, add(light_eye, direction), up);
const auto depth = std::max(2.f, furthest_ray - nearest_ray + 2.f);
result.view_projection = multiply(orthographic(left, right, bottom, top, .1f, depth),
view);
return result;
}
ShadowView local_view(const LocalLight& light, std::uint32_t face,
std::span<const ShadowCasterBounds> casters) {
ShadowView result;
result.kind = light.kind == LocalLight::Kind::Point ? ShadowView::Kind::Point
: ShadowView::Kind::Spot;
result.light_id = light.stable_id;
result.face_index = face;
static constexpr std::array<Vec3, 6> axes{{{1, 0, 0}, {-1, 0, 0}, {0, 1, 0},
{0, -1, 0}, {0, 0, 1}, {0, 0, -1}}};
static constexpr std::array<Vec3, 6> ups{{{0, -1, 0}, {0, -1, 0}, {0, 0, 1},
{0, 0, -1}, {0, -1, 0}, {0, -1, 0}}};
const auto direction = light.kind == LocalLight::Kind::Point ? axes.at(face)
: unit(light.direction);
const auto up = light.kind == LocalLight::Kind::Point ? ups.at(face)
: std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto near_plane = std::max(.0001f, std::min(.05f, light.range * .1f));
const auto fov = light.kind == LocalLight::Kind::Point
? std::numbers::pi_v<float> / 2 : light.outer_angle * 2;
result.view_projection = multiply(
perspective(fov, 1, near_plane, light.range),
look_at(light.position, add(light.position, direction), up));
result.caster_indices = visible_casters(result.view_projection, casters);
return result;
}
} // namespace
ShadowPlan build_shadow_plan(const Snapshot& frame,
std::span<const ShadowCasterBounds> casters,
const ShadowBudget& budget) {
for (const auto& caster : casters)
for (int axis = 0; axis < 3; ++axis)
if (!std::isfinite(caster.world.min[axis]) ||
!std::isfinite(caster.world.max[axis]) ||
caster.world.min[axis] > caster.world.max[axis])
throw std::invalid_argument("Shadow caster world bounds must be finite and ordered");
ShadowPlan plan;
plan.sun_atlas_size = supported_atlas(budget.sun_atlas_size, budget.sun_atlas_available)
? budget.sun_atlas_size : 0;
plan.local_atlas_size = supported_atlas(budget.local_atlas_size, budget.local_atlas_available)
? budget.local_atlas_size : 0;
std::unordered_set<std::string> ids;
std::vector<std::pair<std::size_t, float>> ranked;
ranked.reserve(frame.local_lights.size());
for (std::size_t i = 0; i < frame.local_lights.size(); ++i) {
const auto& light = frame.local_lights[i];
validate_local(light); // Validate overflow records too, before truncation.
if (!ids.insert(light.stable_id).second)
throw std::invalid_argument("Duplicate local light stable_id: " + light.stable_id);
ranked.emplace_back(i, projected_influence(light, frame));
}
std::sort(ranked.begin(), ranked.end(), [&](const auto& a, const auto& b) {
const auto& left = frame.local_lights[a.first];
const auto& right = frame.local_lights[b.first];
if (left.shadow_priority != right.shadow_priority)
return left.shadow_priority > right.shadow_priority;
if (a.second != b.second)
return a.second > b.second;
return left.stable_id < right.stable_id;
});
const auto selected = std::min<std::size_t>(ranked.size(),
std::min(budget.max_local_lights, 128u));
plan.omitted_local_lights = static_cast<std::uint32_t>(ranked.size() - selected);
for (std::size_t i = 0; i < selected; ++i)
plan.submitted_local_indices.push_back(ranked[i].first);
std::optional<SunLight> sun = frame.sun;
if (!sun && !frame.authored_lights_present && frame.local_lights.empty())
sun = SunLight{"legacy-sun", frame.light_direction, {1, 1, 1, 1}, 1, true};
if (sun && sun->casts_shadow) {
plan.requested_sun_cascades = frame.camera_frustum
? std::min(4u, budget.max_sun_views) : 1u;
if (frame.camera_frustum &&
(frame.camera_frustum->near_plane <= 0 ||
frame.camera_frustum->far_plane <= frame.camera_frustum->near_plane ||
frame.camera_frustum->projection[0] == 0 ||
frame.camera_frustum->projection[5] == 0))
throw std::invalid_argument("Shadow camera frustum is invalid");
const float near_plane = frame.camera_frustum ? frame.camera_frustum->near_plane : .1f;
const float far_plane = frame.camera_frustum
? std::min(frame.camera_frustum->far_plane, budget.max_shadow_distance) : 80.f;
if (far_plane <= near_plane)
throw std::invalid_argument("Shadow distance does not reach the camera near plane");
float previous = near_plane;
for (std::uint32_t i = 0; i < plan.requested_sun_cascades; ++i) {
const auto ratio = float(i + 1) / float(plan.requested_sun_cascades);
const auto logarithmic = near_plane * std::pow(far_plane / near_plane, ratio);
const auto uniform = near_plane + (far_plane - near_plane) * ratio;
const auto split = i + 1 == plan.requested_sun_cascades
? far_plane : .5f * (logarithmic + uniform);
ShadowView view;
if (plan.sun_atlas_size)
view = sun_view(frame, *sun, casters, previous, split, i, plan.sun_atlas_size);
else {
view.kind = ShadowView::Kind::Sun;
view.light_id = sun->stable_id;
view.face_index = i;
view.split_near = previous;
view.split_far = split;
view.reason = ShadowDropReason::Unavailable;
}
if (plan.sun_atlas_size &&
view.caster_indices.size() <=
budget.max_caster_draws - std::min(plan.caster_draws, budget.max_caster_draws)) {
view.valid = true;
plan.caster_draws += static_cast<std::uint32_t>(view.caster_indices.size());
++plan.effective_sun_cascades;
} else {
if (plan.sun_atlas_size)
view.reason = ShadowDropReason::CasterBudget;
view.caster_indices.clear();
++plan.dropped_sun_views;
}
plan.sun_views.push_back(std::move(view));
previous = split;
}
}
for (const auto source : plan.submitted_local_indices) {
const auto& light = frame.local_lights[source];
LocalShadowAssignment assignment;
assignment.source_index = source;
assignment.first_view = static_cast<std::uint32_t>(plan.local_views.size());
const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u;
if (!light.casts_shadow || light.intensity == 0) {
plan.local_assignments.push_back(assignment);
continue;
}
plan.local_faces_requested += faces;
if (!plan.local_atlas_size)
assignment.reason = ShadowDropReason::Unavailable;
else if (const auto capacity = std::min(budget.max_local_faces, 16u);
faces > capacity - std::min(plan.local_faces_used, capacity))
assignment.reason = ShadowDropReason::TileBudget;
else {
std::vector<ShadowView> group;
std::uint32_t group_draws{};
for (std::uint32_t face = 0; face < faces; ++face) {
auto view = local_view(light, face, casters);
tile(view, plan.local_atlas_size, 4,
plan.local_faces_used + face);
group_draws += static_cast<std::uint32_t>(view.caster_indices.size());
group.push_back(std::move(view));
}
if (group_draws > budget.max_caster_draws -
std::min(plan.caster_draws, budget.max_caster_draws))
assignment.reason = ShadowDropReason::CasterBudget;
else {
assignment.valid = true;
assignment.face_count = faces;
plan.local_faces_used += faces;
plan.caster_draws += group_draws;
for (auto& view : group) {
view.valid = true;
plan.local_views.push_back(std::move(view));
}
}
}
if (!assignment.valid) {
plan.dropped_local_faces += faces;
if (light.kind == LocalLight::Kind::Point)
plan.dropped_point_faces += 6;
}
plan.local_assignments.push_back(assignment);
}
return plan;
}
} // namespace faset::render
+190 -21
View File
@@ -6,14 +6,17 @@
#include <bit>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <cstring>
#include <faset/core/io.hpp>
#include <faset/render/render_graph.hpp>
#include <faset/render/lighting.hpp>
#include <faset/render/renderer.hpp>
#include <faset/render/visibility.hpp>
#include <fstream>
#include <iostream>
#include <limits>
#include <numbers>
#include <optional>
#include <stdexcept>
#include <unordered_map>
@@ -72,6 +75,40 @@ struct ScenePush {
std::array<std::uint32_t, 4> draw_info;
};
static_assert(sizeof(ScenePush) == 112);
struct LightingHeaderGpu {
std::array<std::uint32_t, 4> counts{};
std::array<float, 4> sun_direction_intensity{};
std::array<float, 4> sun_color{};
std::array<float, 4> camera_forward_shadow_distance{};
std::array<float, 4> cascade_splits{};
};
struct LocalLightGpu {
std::array<float, 4> position_range{};
std::array<float, 4> direction_cos_outer{};
std::array<float, 4> color_intensity{};
std::array<float, 4> cone_type_shadow_view{};
std::array<float, 4> reserved{};
};
struct ShadowViewGpu {
Mat4 view_projection{identity};
std::array<float, 4> tile_scale_offset{};
std::array<float, 4> guarded_clamp{};
std::array<float, 4> bias_flags{};
};
static_assert(sizeof(LightingHeaderGpu) == 80 &&
offsetof(LightingHeaderGpu, sun_direction_intensity) == 16 &&
offsetof(LightingHeaderGpu, sun_color) == 32 &&
offsetof(LightingHeaderGpu, camera_forward_shadow_distance) == 48 &&
offsetof(LightingHeaderGpu, cascade_splits) == 64);
static_assert(sizeof(LocalLightGpu) == 80 &&
offsetof(LocalLightGpu, direction_cos_outer) == 16 &&
offsetof(LocalLightGpu, color_intensity) == 32 &&
offsetof(LocalLightGpu, cone_type_shadow_view) == 48 &&
offsetof(LocalLightGpu, reserved) == 64);
static_assert(sizeof(ShadowViewGpu) == 112 &&
offsetof(ShadowViewGpu, tile_scale_offset) == 64 &&
offsetof(ShadowViewGpu, guarded_clamp) == 80 &&
offsetof(ShadowViewGpu, bias_flags) == 96);
std::array<float, 4> point(const Mat4& m, std::array<float, 4> p) {
std::array<float, 4> o{};
for (int r = 0; r < 4; ++r)
@@ -196,6 +233,7 @@ struct Renderer::Impl {
std::vector<VkImageLayout> swap_layouts;
Image color, depth, shadow;
Buffer vertices, readback;
Buffer lighting_header, lighting_locals, lighting_views;
SceneResources scene;
InstanceTracker instance_tracker;
std::unordered_map<std::string, std::size_t> previous_lods;
@@ -212,6 +250,9 @@ struct Renderer::Impl {
std::unordered_map<const Texture*, CachedOpacity> opacity_cache;
VkDescriptorSetLayout descriptor_layout{};
VkDescriptorPool descriptor_pool{};
VkDescriptorSetLayout lighting_layout{};
VkDescriptorPool lighting_pool{};
VkDescriptorSet lighting_set{};
VkSampler shadow_sampler{}, color_sampler{};
VkPipelineLayout pipeline_layout{};
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{};
@@ -316,6 +357,9 @@ struct Renderer::Impl {
destroy(scene.hzb[1]);
destroy(vertices);
destroy(readback);
destroy(lighting_header);
destroy(lighting_locals);
destroy(lighting_views);
destroy(color);
destroy(depth);
destroy(shadow);
@@ -333,8 +377,12 @@ struct Renderer::Impl {
vkDestroyPipelineLayout(device, pipeline_layout, nullptr);
if (descriptor_pool)
vkDestroyDescriptorPool(device, descriptor_pool, nullptr);
if (lighting_pool)
vkDestroyDescriptorPool(device, lighting_pool, nullptr);
if (descriptor_layout)
vkDestroyDescriptorSetLayout(device, descriptor_layout, nullptr);
if (lighting_layout)
vkDestroyDescriptorSetLayout(device, lighting_layout, nullptr);
if (shadow_sampler)
vkDestroySampler(device, shadow_sampler, nullptr);
if (color_sampler)
@@ -889,6 +937,31 @@ struct Renderer::Impl {
pi.pPoolSizes = sizes;
check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool),
"Create descriptor pool");
std::array<VkDescriptorSetLayoutBinding, 4> lighting_bindings{};
for (std::uint32_t i = 0; i < lighting_bindings.size(); ++i)
lighting_bindings[i] = {i,
i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE
: VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr};
li.bindingCount = static_cast<std::uint32_t>(lighting_bindings.size());
li.pBindings = lighting_bindings.data();
check(vkCreateDescriptorSetLayout(device, &li, nullptr, &lighting_layout),
"Create lighting descriptor layout");
VkDescriptorPoolSize lighting_sizes[] = {
{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3}, {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1}};
pi.flags = 0;
pi.maxSets = 1;
pi.poolSizeCount = 2;
pi.pPoolSizes = lighting_sizes;
check(vkCreateDescriptorPool(device, &pi, nullptr, &lighting_pool),
"Create lighting descriptor pool");
VkDescriptorSetAllocateInfo lighting_allocation{};
lighting_allocation.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
lighting_allocation.descriptorPool = lighting_pool;
lighting_allocation.descriptorSetCount = 1;
lighting_allocation.pSetLayouts = &lighting_layout;
check(vkAllocateDescriptorSets(device, &lighting_allocation, &lighting_set),
"Allocate lighting descriptors");
VkSamplerCreateInfo si{};
si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
si.magFilter = si.minFilter = VK_FILTER_NEAREST;
@@ -1009,8 +1082,9 @@ struct Renderer::Impl {
sizeof(Push)};
VkPipelineLayoutCreateInfo li{};
li.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
li.setLayoutCount = 1;
li.pSetLayouts = &descriptor_layout;
const std::array<VkDescriptorSetLayout, 2> set_layouts{descriptor_layout, lighting_layout};
li.setLayoutCount = static_cast<std::uint32_t>(set_layouts.size());
li.pSetLayouts = set_layouts.data();
li.pushConstantRangeCount = 1;
li.pPushConstantRanges = &push;
check(vkCreatePipelineLayout(device, &li, nullptr, &pipeline_layout),
@@ -1220,14 +1294,14 @@ struct Renderer::Impl {
layout.pBindings = hzb.data();
check(vkCreateDescriptorSetLayout(device, &layout, nullptr, &scene.hzb_layout),
"Create HZB descriptor layout");
const std::array<VkDescriptorSetLayout, 2> scene_layouts{descriptor_layout,
scene.graphics_layout};
const std::array<VkDescriptorSetLayout, 3> scene_layouts{
descriptor_layout, lighting_layout, scene.graphics_layout};
VkPushConstantRange graphics_push{VK_SHADER_STAGE_VERTEX_BIT |
VK_SHADER_STAGE_FRAGMENT_BIT,
0, sizeof(ScenePush)};
VkPipelineLayoutCreateInfo pipeline_info{};
pipeline_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipeline_info.setLayoutCount = 2;
pipeline_info.setLayoutCount = static_cast<std::uint32_t>(scene_layouts.size());
pipeline_info.pSetLayouts = scene_layouts.data();
pipeline_info.pushConstantRangeCount = 1;
pipeline_info.pPushConstantRanges = &graphics_push;
@@ -1533,6 +1607,29 @@ struct Renderer::Impl {
VkBufferUsageFlags usage = 0) {
upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage);
}
void update_lighting_descriptors() {
const std::array<VkDescriptorBufferInfo, 3> buffers{{
{lighting_header.handle, 0, lighting_header.size},
{lighting_locals.handle, 0, lighting_locals.size},
{lighting_views.handle, 0, lighting_views.size}}};
const VkDescriptorImageInfo atlas{VK_NULL_HANDLE, shadow.view,
VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL};
std::array<VkWriteDescriptorSet, 4> writes{};
for (std::uint32_t i = 0; i < writes.size(); ++i) {
writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[i].dstSet = lighting_set;
writes[i].dstBinding = i;
writes[i].descriptorCount = 1;
writes[i].descriptorType = i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE
: VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
if (i == 3)
writes[i].pImageInfo = &atlas;
else
writes[i].pBufferInfo = &buffers[i];
}
vkUpdateDescriptorSets(device, static_cast<std::uint32_t>(writes.size()),
writes.data(), 0, nullptr);
}
void update_scene_descriptors(bool occlusion) {
auto write_buffers = [&](VkDescriptorSet set, std::span<const Buffer* const> buffers,
std::uint32_t first_binding) {
@@ -1625,6 +1722,7 @@ struct Renderer::Impl {
void render(const Snapshot& snapshot) {
auto start = std::chrono::steady_clock::now();
statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0;
statistics.submitted_local_lights = statistics.omitted_local_lights = 0;
statistics.gpu_bins = statistics.gpu_visible_instances =
statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred =
statistics.gpu_post_visible = 0;
@@ -1715,6 +1813,8 @@ struct Renderer::Impl {
};
std::vector<SelectedDraw> selected_draws;
selected_draws.reserve(snapshot.draws.size());
std::vector<ShadowCasterBounds> shadow_casters;
shadow_casters.reserve(snapshot.draws.size());
struct BuildingBin {
const Mesh* mesh{};
const Texture* texture{};
@@ -1724,10 +1824,17 @@ struct Renderer::Impl {
std::vector<BuildingBin> building_bins;
std::unordered_map<const Mesh*, std::pair<std::uint32_t, std::uint32_t>> mesh_ranges;
std::unordered_map<std::string, std::size_t> current_lods;
for (const auto& item : snapshot.draws) {
for (std::size_t source_index = 0; source_index < snapshot.draws.size(); ++source_index) {
const auto& item = snapshot.draws[source_index];
if (!item.mesh || item.mesh->vertices.empty())
continue;
const auto source_bounds = world_bounds(item.mesh, item.model);
if (item.cast_shadow) {
if (source_index > UINT32_MAX)
throw std::overflow_error("Shadow source draw index exceeds 32-bit capacity");
shadow_casters.push_back(
{source_bounds, static_cast<std::uint32_t>(source_index)});
}
std::vector<float> thresholds;
std::vector<std::uint8_t> available;
std::shared_ptr<const Mesh> selected_mesh = item.mesh;
@@ -2017,15 +2124,73 @@ struct Renderer::Impl {
VK_BUFFER_USAGE_TRANSFER_DST_BIT);
update_scene_descriptors(occlusion);
}
Vec3 direction = snapshot.light_direction;
std::optional<SunLight> sun = snapshot.sun;
if (!sun && !snapshot.authored_lights_present && snapshot.local_lights.empty())
sun = SunLight{"legacy-sun", snapshot.light_direction, {1, 1, 1, 1}, 1, true};
Vec3 direction = sun ? sun->direction : snapshot.light_direction;
float length = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] +
direction[2] * direction[2]);
if (length < 1e-5f) {
if (!std::isfinite(length) || length < 1e-5f) {
if (sun && sun->stable_id != "legacy-sun")
throw std::invalid_argument("Authored sun direction must be finite and nonzero");
direction = {-.5f, -1, -.3f};
length = std::sqrt(1.34f);
}
for (auto& v : direction)
v /= length;
LightingHeaderGpu lighting{};
lighting.counts[1] = sun ? 1u : 0u;
lighting.counts[2] = sun && sun->casts_shadow ? 1u : 0u;
lighting.sun_direction_intensity = {direction[0], direction[1], direction[2],
sun ? sun->intensity : 0};
lighting.sun_color = sun ? sun->color : Color{0, 0, 0, 1};
if (sun && (!std::isfinite(sun->intensity) || sun->intensity < 0 ||
std::any_of(sun->color.begin(), sun->color.end(),
[](float v) { return !std::isfinite(v) || v < 0; })))
throw std::invalid_argument("Authored sun radiance must be finite and nonnegative");
lighting.camera_forward_shadow_distance = {0, 0, -1, 80};
if (snapshot.camera_frustum) {
const auto& view = snapshot.camera_frustum->view;
lighting.camera_forward_shadow_distance = {-view[2], -view[6], -view[10], 80};
}
const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters);
statistics.omitted_local_lights = shadow_plan.omitted_local_lights;
std::vector<LocalLightGpu> gpu_lights;
gpu_lights.reserve(shadow_plan.submitted_local_indices.size());
for (const auto source : shadow_plan.submitted_local_indices) {
const auto& local = snapshot.local_lights[source];
auto spot_direction = local.direction;
float spot_length = std::hypot(spot_direction[0], spot_direction[1],
spot_direction[2]);
if (!std::isfinite(spot_length) || spot_length < 1e-6f) {
if (local.kind == LocalLight::Kind::Spot)
throw std::invalid_argument("Spotlight direction must be finite and nonzero");
spot_direction = {0, 0, -1};
spot_length = 1;
}
for (auto& axis : spot_direction)
axis /= spot_length;
LocalLightGpu gpu{};
gpu.position_range = {local.position[0], local.position[1], local.position[2],
local.range};
const bool spot = local.kind == LocalLight::Kind::Spot;
gpu.direction_cos_outer = {spot_direction[0], spot_direction[1], spot_direction[2],
spot ? std::cos(local.outer_angle) : 0.f};
gpu.color_intensity = {local.color[0], local.color[1], local.color[2],
local.intensity};
gpu.cone_type_shadow_view = {spot ? std::cos(local.inner_angle) : 1.f,
spot ? 1.f : 0.f, -1, 0};
gpu_lights.push_back(gpu);
}
lighting.counts[0] = static_cast<std::uint32_t>(gpu_lights.size());
statistics.submitted_local_lights = lighting.counts[0];
if (gpu_lights.empty())
gpu_lights.push_back({}); // Descriptors always point at a full initialized record.
const ShadowViewGpu empty_shadow_view{};
upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0);
upload_scene_vector(lighting_locals, gpu_lights);
upload_scene_buffer(lighting_views, &empty_shadow_view, sizeof(empty_shadow_view), 0);
update_lighting_descriptors();
Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30};
Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
Push push{multiply(orthographic(-20, 20, -20, 20, .1f, 80),
@@ -2078,6 +2243,12 @@ struct Renderer::Impl {
vkCmdSetViewport(command, 0, 1, &viewport);
vkCmdSetScissor(command, 0, 1, &scissor);
};
auto bind_material = [&](VkDescriptorSet material) {
const std::array<VkDescriptorSet, 2> sets{material, lighting_set};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout,
0, static_cast<std::uint32_t>(sets.size()), sets.data(),
0, nullptr);
};
auto draw_transparent = [&] {
if (transparent_batches.empty())
return;
@@ -2088,8 +2259,7 @@ struct Renderer::Impl {
0, sizeof(push), &push);
for (auto batch : transparent_batches) {
auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipeline_layout, 0, 1, &descriptor, 0, nullptr);
bind_material(descriptor);
vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls;
}
@@ -2102,8 +2272,7 @@ struct Renderer::Impl {
sizeof(push), &push);
for (auto batch : sprite_batches) {
auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout,
0, 1, &descriptor, 0, nullptr);
bind_material(descriptor);
vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls;
}
@@ -2125,8 +2294,7 @@ struct Renderer::Impl {
continue;
vkCmdSetScissor(command, 0, 1, &scissor);
auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout,
0, 1, &descriptor, 0, nullptr);
bind_material(descriptor);
vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls;
}
@@ -2313,8 +2481,7 @@ struct Renderer::Impl {
vkCmdPushConstants(command, pipeline_layout,
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
sizeof(push), &push);
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, 0, 1,
&white_descriptor, 0, nullptr);
bind_material(white_descriptor);
if (gpu_active && !gpu_frame.bins.empty()) {
VkDeviceSize scene_offset{};
vkCmdBindVertexBuffers(command, 0, 1, &scene.vertices.handle, &scene_offset);
@@ -2322,7 +2489,7 @@ struct Renderer::Impl {
scene.graphics_pipeline);
for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) {
auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor;
const std::array<VkDescriptorSet, 2> sets{descriptor,
const std::array<VkDescriptorSet, 3> sets{descriptor, lighting_set,
scene.graphics_main};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
scene.graphics_pipeline_layout, 0, sets.size(),
@@ -2346,8 +2513,7 @@ struct Renderer::Impl {
}
for (auto batch : scene_batches) {
auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout,
0, 1, &descriptor, 0, nullptr);
bind_material(descriptor);
vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls;
}
@@ -2446,7 +2612,7 @@ struct Renderer::Impl {
scene.graphics_pipeline);
for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) {
auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor;
const std::array<VkDescriptorSet, 2> sets{descriptor,
const std::array<VkDescriptorSet, 3> sets{descriptor, lighting_set,
scene.graphics_post};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
scene.graphics_pipeline_layout, 0,
@@ -2618,7 +2784,10 @@ struct Renderer::Impl {
++statistics.frame;
statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size +
color.allocation_size + depth.allocation_size +
shadow.allocation_size;
shadow.allocation_size +
lighting_header.allocation_size +
lighting_locals.allocation_size +
lighting_views.allocation_size;
statistics.texture_count = static_cast<std::uint32_t>(textures.size());
for (const auto& [_, texture] : textures)
statistics.gpu_allocated_bytes += texture.image.allocation_size;
+12 -4
View File
@@ -34,13 +34,21 @@ void validate_layout(const Json& layout, std::string_view entry) {
const bool fragment = entry == "fragmentMain";
require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed");
const auto& descriptors = layout.at("descriptors");
require(descriptors.is_array() && descriptors.size() == 4, "descriptor count changed");
require(descriptors.is_array() && descriptors.size() == 8, "descriptor count changed");
for (std::size_t i = 0; i < descriptors.size(); ++i) {
const auto& binding = descriptors[i];
require(binding.at("set") == 0 && binding.at("binding") == i && binding.at("count") == 1,
const auto set = i < 4 ? 0 : 1;
const auto slot = i % 4;
require(binding.at("set") == set && binding.at("binding") == slot &&
binding.at("count") == 1,
"descriptor set, binding or array count changed");
require(binding.at("type") == (i % 2 ? "sampler" : "sampled_image_2d"),
const auto* expected_type = set == 0 ? (slot % 2 ? "sampler" : "sampled_image_2d")
: slot == 3 ? "sampled_image_2d" : "storage_buffer";
require(binding.at("type") == expected_type,
"descriptor type changed");
if (set == 1 && slot < 3)
require(binding.at("element_stride") == (slot == 2 ? 112 : 80),
"lighting storage record stride changed");
require(fragment || !binding.at("used").get<bool>(),
"vertex texture bindings are unsupported");
}
@@ -98,7 +106,7 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) {
const std::array<int, 3> graphics_strides{224, 4, 208};
for (std::size_t i = 0; i < expected_count; ++i) {
const auto& binding = descriptors[i];
require(binding.at("set") == (graphics ? 1 : 0) && binding.at("binding") == i &&
require(binding.at("set") == (graphics ? 2 : 0) && binding.at("binding") == i &&
binding.at("count") == 1,
"GPU descriptor set, binding or count changed");
const int stride = graphics ? graphics_strides[i] : hzb ? 0 : compute_strides[i];
+27
View File
@@ -2,6 +2,7 @@
#include <faset/authoring/templates.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/io.hpp>
#include <cstdint>
#include <iostream>
#define CHECK(x) \
@@ -25,6 +26,32 @@ int main() {
const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id());
try {
auto schemas = builtin_schemas();
const auto light = schemas.schema("faset.light");
CHECK(light["version"] == 1);
const auto defaults = schemas.default_fields("faset.light");
CHECK(defaults["kind"] == "directional");
CHECK(defaults["enabled"] == true);
CHECK(defaults["range"] == 10.0);
CHECK(defaults["inner_angle"] < defaults["outer_angle"]);
CHECK(defaults["casts_shadow"] == true);
CHECK(defaults["shadow_priority"] == 0);
auto light_component = Json{{"type", "faset.light"}, {"version", 1}, {"fields", defaults}};
schemas.validate_component(light_component);
light_component["fields"]["kind"] = "area";
fails([&] { schemas.validate_component(light_component); }, "validation.enum");
light_component["fields"]["kind"] = "point";
light_component["fields"]["range"] = 0;
fails([&] { schemas.validate_component(light_component); }, "validation.minimum");
light_component["fields"]["range"] = 10;
light_component["fields"]["intensity"] = -1;
fails([&] { schemas.validate_component(light_component); }, "validation.minimum");
light_component["fields"] = {{"kind", "spot"}, {"inner_angle", 0.9}};
fails([&] { schemas.validate_component(light_component); }, "validation.light_cone");
light_component["fields"] = {{"kind", "spot"},
{"inner_angle", 0.2},
{"outer_angle", 0.5},
{"shadow_priority", std::int64_t{2147483648}}};
fails([&] { schemas.validate_component(light_component); }, "validation.maximum");
AuthoringService service(root, schemas);
auto created = service.create("Courtyard", 3);
const std::string id = created["id"];
-196
View File
@@ -1,196 +0,0 @@
#include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <iostream>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool condition, std::string_view message) {
if (!condition)
throw std::runtime_error(std::string(message));
}
void run(const fs::path& root) {
const auto project = root / "project";
const auto scripts = project / "Scripts";
atomic_write(scripts / "Gameplay.cpp", "#include \"Gameplay.hpp\"\n");
atomic_write(scripts / "Gameplay.hpp", "#pragma once\n");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 1\n");
const auto compiler = root / "fake-compiler";
const auto cmake = root / "fake-cmake";
const auto slang = root / "fake-slangc";
atomic_write(compiler, "compiler-v1\n");
atomic_write(cmake, "cmake-v1\n");
atomic_write(slang, "slang-v1\n");
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
config.build_directory = project / ".faset/build";
config.cmake = path_to_utf8(cmake);
config.configure_arguments = {
"-DCMAKE_C_COMPILER=" + path_to_utf8(compiler),
"-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler),
"-DSLANGC_EXECUTABLE=" + path_to_utf8(slang)};
const scripting::LuaProject none;
const auto initial = editor::capture_build_inputs(config, none);
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
const auto changed_header = editor::capture_build_inputs(config, none);
check(initial.source_hash != changed_header.source_hash &&
initial.fingerprint() != changed_header.fingerprint(),
"Nested gameplay header changes the complete source snapshot");
atomic_write(scripts / "main.lua", "return {}\n");
atomic_write_json(project / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"scripting", {{"lua", {{"scripts", {"Scripts/main.lua"}}}}}}});
auto lua = scripting::loadLuaProject(project);
const auto with_lua = editor::capture_build_inputs(config, lua);
atomic_write(scripts / "other.lua", "return {}\n");
auto manifest = read_json(project / "project.faset.json");
manifest["scripting"]["lua"]["scripts"].push_back("Scripts/other.lua");
atomic_write_json(project / "project.faset.json", manifest);
lua = scripting::loadLuaProject(project);
const auto changed_declaration = editor::capture_build_inputs(config, lua);
check(with_lua.source_hash != changed_declaration.source_hash,
"Lua entry declaration changes the source snapshot");
const auto native = config.build_directory / "Debug";
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
atomic_write(native / "sentinel", "keep\n");
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
check(fs::exists(native / "sentinel"), "Unchanged toolchain preserves the native tree");
atomic_write(compiler, "compiler-v2\n");
const auto changed_tool = editor::capture_build_inputs(config, lua);
check(changed_tool.source_hash == changed_declaration.source_hash &&
changed_tool.toolchain_hash != changed_declaration.toolchain_hash,
"Changing compiler bytes at the same path changes toolchain identity");
editor::ensure_native_toolchain_stamp(config, native, changed_tool);
check(!fs::exists(native / "sentinel"),
"Changed toolchain invalidates only the generated native tree");
const auto user_directory = root / "important-user-data";
atomic_write(user_directory / "do-not-delete.txt", "valuable\n");
bool refused_unowned{};
try {
editor::ensure_native_toolchain_stamp(config, user_directory, changed_tool);
} catch (const std::exception&) {
refused_unowned = true;
}
check(refused_unowned && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Unowned native directory is never cleared");
std::error_code native_link_error;
fs::create_directory_symlink(user_directory, config.build_directory / "Release",
native_link_error);
if (!native_link_error) {
bool refused_link{};
try {
editor::ensure_native_toolchain_stamp(config, config.build_directory / "Release",
changed_tool);
} catch (const std::exception&) {
refused_link = true;
}
check(refused_link && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Symlinked native directory cannot redirect cleanup into user data");
}
const auto relative_tool = project / "tools/cmake-fixture";
atomic_write(relative_tool, "relative-cmake-v1\n");
auto relative_config = config;
relative_config.cmake = "tools/cmake-fixture";
const auto relative_before = editor::capture_build_inputs(relative_config, lua);
atomic_write(relative_tool, "relative-cmake-v2\n");
check(relative_before.toolchain_hash !=
editor::capture_build_inputs(relative_config, lua).toolchain_hash,
"Relative CMake executable resolves from the project working directory");
auto typed_config = config;
const auto typed_compiler = root / "typed-compiler";
atomic_write(typed_compiler, "typed-v1\n");
typed_config.configure_arguments = {
"-DCMAKE_C_COMPILER:FILEPATH=" + path_to_utf8(typed_compiler)};
const auto typed_before = editor::capture_build_inputs(typed_config, lua);
atomic_write(typed_compiler, "typed-v2\n");
check(typed_before.toolchain_hash !=
editor::capture_build_inputs(typed_config, lua).toolchain_hash,
"Typed CMake compiler overrides are included in toolchain identity");
auto malicious_config = config;
malicious_config.configure_arguments.push_back(
"-DFASET_GAMEPLAY_SOURCE_DIR:PATH=" + path_to_utf8(root / "outside"));
bool refused_source_override{};
try {
(void)editor::capture_build_inputs(malicious_config, lua);
} catch (const std::exception&) {
refused_source_override = true;
}
check(refused_source_override,
"Configure arguments cannot redirect gameplay compilation away from hashed Scripts");
const auto staged = editor::stage_gameplay_sources(config, changed_tool, lua,
"snapshot-fixture");
check(staged != scripts && fs::is_regular_file(staged / "Gameplay.cpp") &&
read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"Native gameplay compilation uses a complete staged Scripts snapshot");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 999\n");
check(read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"A live edit cannot alter the source bytes of an in-flight native build");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
check(editor::stage_gameplay_sources(config, changed_tool, lua, "snapshot-again") ==
staged,
"Identical sources retain a stable native path for incremental Ninja builds");
atomic_write(native / "faset_player", "player-v1\n");
atomic_write(native / "faset_schema_exporter", "exporter-v1\n");
atomic_write(native / "CMakeCache.txt", "recipe-v1\n");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v1\n");
auto package_key = [&] (const editor::BuildInputs& inputs) {
return editor::build_package_key(inputs, native, "Debug", native / "faset_player",
native / "faset_schema_exporter");
};
const auto first_key = package_key(changed_tool);
check(first_key != package_key(changed_declaration) &&
first_key != package_key(with_lua),
"Toolchain and Lua source changes invalidate package identity");
auto option_config = config;
option_config.configure_arguments.push_back("-DFASET_TEST_OPTION=ON");
check(first_key != package_key(editor::capture_build_inputs(option_config, lua)),
"Configure option changes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v2\n");
check(first_key != package_key(changed_tool),
"Shader bytes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v2\n");
check(first_key != package_key(changed_tool),
"Runtime DLL bytes invalidate package identity");
fs::create_directories(root / "outside");
std::error_code link_error;
fs::create_directory_symlink(root / "outside", scripts / "linked", link_error);
if (!link_error) {
bool rejected = false;
try {
(void)editor::capture_build_inputs(config, lua);
} catch (const std::exception&) {
rejected = true;
}
check(rejected, "Source snapshot refuses a symlink escaping Scripts");
}
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / path_from_utf8("Faset build cache Café 世界 " + new_id());
try {
run(root);
fs::remove_all(root);
std::cout << "Source, toolchain and native package identity contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
fs::remove_all(root);
return 1;
}
}
-76
View File
@@ -1,76 +0,0 @@
#include <faset/editor/build_diagnostics.hpp>
#include <faset/core/io.hpp>
#include <iostream>
#include <stdexcept>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool condition, std::string_view message) {
if (!condition)
throw std::runtime_error(std::string(message));
}
void contracts() {
const auto project = fs::path("/project");
auto rows = editor::parse_build_diagnostics(
"/project/Scripts/Game.cpp:17:4: error: bad field\n", "compile", project);
check(rows.size() == 1 && rows[0].at("file") == "Scripts/Game.cpp" &&
rows[0].at("line") == 17 && rows[0].at("column") == 4 &&
rows[0].at("severity") == "error" && rows[0].at("phase") == "compile",
"Clang error has a project-relative source location");
rows = editor::parse_build_diagnostics(
"\x1b[31mScripts/player.lua:6: unexpected symbol near '='\x1b[0m\n",
"schema", project);
check(rows.size() == 1 && rows[0].at("file") == "Scripts/player.lua" &&
rows[0].at("line") == 6 && rows[0].at("severity") == "error" &&
rows[0].at("message") == "unexpected symbol near '='",
"Lua syntax error and ANSI stripping work");
const auto windows = path_from_utf8(R"(C:\Café)");
rows = editor::parse_build_diagnostics(
R"(C:\Café\Scripts\Game.cpp(17,4): error C2143: syntax error)" "\n"
R"(C:\Café\Scripts\Game.cpp:19:2: warning: suspicious conversion)" "\n",
"compile", windows);
check(rows.size() == 2 && rows[0].at("file") == "Scripts/Game.cpp" &&
rows[0].at("code") == "C2143" && rows[0].at("line") == 17 &&
rows[1].at("severity") == "warning" && rows[1].at("line") == 19,
"Windows drive colon and Unicode directory remain intact");
rows = editor::parse_build_diagnostics(
"Scripts/Game.cpp:9:3: error: missing value\n"
" broken call\n"
" ^~~~~~\n"
"Scripts/Game.cpp:4:1: note: declared here\n",
"compile", project);
check(rows.size() == 2 && rows[0].at("severity") == "error" &&
rows[1].at("severity") == "note" && rows[1].at("file") == "Scripts/Game.cpp",
"Caret and source excerpts do not become duplicate diagnostics");
rows = editor::parse_build_diagnostics(
"/outside/Scripts/Game.cpp:4:2: error: external\n"
"../Scripts/Escape.cpp:5:2: error: escaped\n",
"compile", project);
check(rows.size() == 2 && !rows[0].contains("file") && !rows[1].contains("file"),
"External and traversing sources are never navigable");
rows = editor::parse_build_diagnostics(
"Scripts/../Scripts/Game.cpp:4:2: error: disguised traversal\n"
"Scripts/Game.cpp:4:0: error: invalid column\n",
"compile", project);
check(rows.size() == 1 && !rows[0].contains("file"),
"Traversal stays non-navigable and zero columns are rejected");
std::string noisy;
for (int index = 0; index < 300; ++index)
noisy += "Scripts/Game.cpp:2:1: warning: repeated\n";
check(editor::parse_build_diagnostics(noisy, "compile", project).size() <= 200,
"Structured diagnostic count is bounded");
}
} // namespace
int main() {
try {
contracts();
std::cout << "Build diagnostic parsing contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+3 -101
View File
@@ -142,72 +142,11 @@ 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);
{
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());
const 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"})
@@ -222,34 +161,6 @@ 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");
@@ -258,11 +169,6 @@ 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));
@@ -346,11 +252,9 @@ 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().filename().string().starts_with(".staging-"),
"Invalid build leaves no staging generation");
check(entry.path() == directory, "Invalid build leaves no staging or generation");
}
check(generations == published_generations,
"Invalid build publishes no new generation");
check(generations == 1, "Only the validated build generation remains");
bool rejected{};
try {
authoring.replace_external_schemas(invalid[index]);
@@ -401,8 +305,6 @@ 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,
+1 -16
View File
@@ -8,9 +8,6 @@ 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");
@@ -24,8 +21,6 @@ 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;
}
@@ -39,18 +34,8 @@ 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 (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;
}
if (argc > 2 && std::string_view(argv[1]) == "--build")
return 0;
}
fs::path build;
for (int i = 1; i + 1 < argc; ++i)
if (std::string_view(argv[i]) == "-B")
-101
View File
@@ -1,12 +1,8 @@
#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>
@@ -46,102 +42,6 @@ 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");
@@ -479,7 +379,6 @@ int test_main(int argc, char** argv) {
try {
fs::create_directories(temporary);
lua_project_contracts(temporary / "lua-project");
starter_contracts(temporary / "starters");
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
const auto executable = temporary / original_executable.filename();
fs::copy_file(original_executable, executable);
-119
View File
@@ -1,119 +0,0 @@
#include <faset/authoring/service.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/autosave.hpp>
#include <chrono>
#include <iostream>
using namespace faset;
using namespace std::chrono_literals;
namespace fs = std::filesystem;
namespace {
void check(bool value, std::string_view message) {
if (!value)
throw std::runtime_error(std::string(message));
}
void contracts(const fs::path& root) {
authoring::AuthoringService service(root);
const auto created = service.create("Before", 2);
const auto id = created.at("id").get<std::string>();
const auto path = root / "Scenes/main.scene.json";
service.save(id, "Scenes/main.scene.json");
int saves{};
editor::AutosaveController autosave([&](const std::string& document, std::uint64_t revision) {
++saves;
return service.save(document, {}, revision);
});
using Clock = editor::AutosaveController::Clock;
const auto start = Clock::time_point{};
autosave.observe(service.documents(), start, true);
for (int index = 1; index <= 3; ++index) {
const auto revision = service.query(id).at("revision").get<std::uint64_t>();
service.transact(id, revision,
Json::array({{{"op", "scene.rename"},
{"name", "Edit " + std::to_string(index)}}}));
autosave.observe(service.documents(), start + index * 100ms, true);
}
const auto play_snapshot = service.query(id).at("scene");
autosave.observe(service.documents(), start + 2299ms, true);
check(saves == 0 && read_json(path).at("name") == "Before",
"Rapid edits coalesce until the last revision is idle for two seconds");
autosave.observe(service.documents(), start + 2301ms, true);
check(saves == 1 && read_json(path).at("name") == "Edit 3" &&
service.query(id).at("scene") == play_snapshot &&
autosave.status().at("documents")[0].at("state") == "saved",
"Named scene saves once without changing the Play snapshot");
const auto current_revision = service.query(id).at("revision").get<std::uint64_t>();
check(service.undo(id, current_revision).at("scene").at("name") == "Edit 2",
"Undo remains available after autosave");
bool stale_rejected{};
try {
service.save(id, {}, current_revision);
} catch (const Error& error) {
stale_rejected = error.code() == "revision.conflict";
}
check(stale_rejected, "A stale autosave revision cannot save a newer document");
const auto external = read_text(path) + " \n";
atomic_write(path, external);
const auto revision = service.query(id).at("revision").get<std::uint64_t>();
service.transact(id, revision,
Json::array({{{"op", "scene.rename"}, {"name", "After external edit"}}}));
autosave.observe(service.documents(), start + 3000ms, true);
autosave.observe(service.documents(), start + 5100ms, true);
check(saves == 2 && read_text(path) == external &&
autosave.status().at("documents")[0].at("state") == "conflict",
"Disk conflict does not overwrite external changes");
autosave.observe(service.documents(), start + 10000ms, true);
check(saves == 2, "A failed autosave does not retry every polling frame");
const auto unnamed = service.create("Unnamed", 2);
autosave.observe(service.documents(), start + 10100ms, true);
bool found_unnamed{};
const auto unnamed_status = autosave.status();
for (const auto& row : unnamed_status.at("documents"))
if (row.at("id") == unnamed.at("id"))
found_unnamed = row.at("state") == "save_as_required";
check(found_unnamed &&
fs::is_regular_file(root / ".faset/recovery" /
(unnamed.at("id").get<std::string>() + ".json")),
"Unnamed scene remains recoverable without receiving an implicit save path");
autosave.observe(service.documents(), start + 10200ms, false);
check(!autosave.status().at("enabled").get<bool>() && saves == 2,
"Disabled autosave leaves recovery journaling active");
int failed_attempts{};
editor::AutosaveController failing([&](const std::string&, std::uint64_t) -> Json {
++failed_attempts;
throw Error("io.write", "Synthetic read-only destination");
});
Json fake_documents = Json::array(
{{{"id", "failure-fixture"}, {"revision", 1},
{"path", "Scenes/fixture.scene.json"}, {"dirty", true}}});
failing.observe(fake_documents, start, true);
failing.observe(fake_documents, start + 2100ms, true);
check(failed_attempts == 1 &&
failing.status().at("documents")[0].at("state") == "failed",
"Write failures remain visible");
failing.observe(fake_documents, start + 10000ms, true);
check(failed_attempts == 1, "A failed write does not retry on every frame");
fake_documents[0]["revision"] = 2;
failing.observe(fake_documents, start + 10100ms, true);
failing.observe(fake_documents, start + 12200ms, true);
check(failed_attempts == 2, "A newer revision receives a fresh autosave deadline");
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / ("faset-autosave-" + new_id());
try {
contracts(root);
fs::remove_all(root);
std::cout << "Revision-aware autosave contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
fs::remove_all(root);
return 1;
}
}
+4 -115
View File
@@ -1,18 +1,9 @@
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
#include <chrono>
#include <iostream>
#include <thread>
int test_main(int argc, char** argv) {
int main() {
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 {
@@ -36,8 +27,6 @@ int test_main(int argc, char** argv) {
require(!session.play_pending() && !session.playing(), "test",
"Stop must cancel a pending Play build");
const auto initial = commands.call("faset_project_settings_get", Json::object());
require(initial.at("settings").at("editor").at("autosave") == true, "test",
"Missing autosave setting must read as enabled");
auto changed = commands.call("faset_project_settings_set",
{{"revision", initial.at("revision")},
{"settings",
@@ -77,57 +66,15 @@ int test_main(int argc, char** argv) {
atomic_write_json(root / "project.faset.json", external);
rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}},
"revision.conflict");
auto reloaded = commands.call("faset_project_settings_get", Json::object());
auto explicit_default = external;
explicit_default["editor"]["autosave"] = true;
atomic_write_json(root / "project.faset.json", explicit_default);
rejects({{"revision", reloaded.at("revision")},
{"settings", {{"name", "Lost external write"}}}},
"revision.conflict");
reloaded = commands.call("faset_project_settings_get", Json::object());
const auto reloaded = commands.call("faset_project_settings_get", Json::object());
changed =
commands.call("faset_project_settings_set", {{"revision", reloaded.at("revision")},
{"settings", {{"name", "Preserved"}}}});
require(changed.at("settings").at("custom_tool").at("keep") == true, "test",
"Saving settings erased unknown project metadata");
require(changed.at("settings").at("scripting") == external.at("scripting") &&
changed.at("settings").at("editor").at("script_editor") ==
external.at("editor").at("script_editor"),
changed.at("settings").at("editor") == external.at("editor"),
"test", "Project settings erased Lua configuration or external editor command");
const auto autosave_off = commands.call(
"faset_project_settings_set",
{{"revision", changed.at("revision")},
{"settings", {{"editor", {{"autosave", false}}}}}});
require(autosave_off.at("settings").at("editor").at("autosave") == false &&
session.project().at("editor").at("script_editor") ==
external.at("editor").at("script_editor"),
"test", "Autosave setting must merge without erasing the editor command");
session.poll();
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == false,
"test", "Autosave disable must take effect in the current session");
{
editor::Session reopened({root, path_from_utf8(FASET_TEST_ENGINE), {}});
reopened.poll();
require(reopened.commands()
.call("faset_autosave_status", Json::object())
.at("enabled") == false,
"test", "Autosave setting must survive Editor reopen");
}
rejects({{"revision", changed.at("revision")},
{"settings", {{"editor", {{"autosave", true}}}}}},
"revision.conflict");
rejects({{"revision", autosave_off.at("revision")},
{"settings", {{"editor", {{"autosave", "sometimes"}}}}}},
"project.autosave");
rejects({{"revision", autosave_off.at("revision")},
{"settings", {{"editor", {{"script_editor", {"other"}}}}}}},
"project.setting");
changed = commands.call("faset_project_settings_set",
{{"revision", autosave_off.at("revision")},
{"settings", {{"editor", {{"autosave", true}}}}}});
session.poll();
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true,
"test", "Autosave enable must take effect in the current session");
const auto setup = commands.call("faset_lua_setup", Json::object());
require(setup.at("configuration_created") == true &&
setup.at("scripts_configuration_created") == true &&
@@ -168,37 +115,6 @@ int test_main(int argc, char** argv) {
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
"lua.editor");
const auto probe = root / "editor-argv.json";
const auto opened = commands.call(
"faset_source_open",
{{"path", "Scripts/Gameplay.cpp"}, {"line", 17}, {"column", 4},
{"editor", Json::array({path_to_utf8(std::filesystem::absolute(path_from_utf8(argv[0]))),
"--editor-probe", path_to_utf8(probe),
"{file}:{line}:{column}", "{project}"})}});
for (int attempt = 0; attempt < 100 && !std::filesystem::exists(probe); ++attempt)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
const auto canonical_root = std::filesystem::weakly_canonical(root);
const auto expected_source = project_path(root, "Scripts/Gameplay.cpp");
const auto expected_arguments =
Json::array({path_to_utf8(expected_source) + ":17:4", path_to_utf8(canonical_root)});
const auto actual_arguments =
std::filesystem::is_regular_file(probe) ? read_json(probe) : Json(nullptr);
require(opened.at("path") == "Scripts/Gameplay.cpp" && opened.at("line") == 17 &&
actual_arguments == expected_arguments,
"test", "Source navigation passes a Unicode path and location as literal argv; "
"expected " + expected_arguments.dump() + ", got " +
actual_arguments.dump());
reject_command("faset_source_open", {{"path", "../outside.cpp"}}, "source.path");
reject_command("faset_source_open", {{"path", "Scripts/missing.cpp"}}, "source.path");
reject_command("faset_source_open", {{"path", "Scripts"}}, "source.path");
atomic_write(root / "Scripts/tool.exe", "not a source\n");
reject_command("faset_source_open", {{"path", "Scripts/tool.exe"}}, "source.path");
std::filesystem::create_directories(root / "Outside");
std::error_code link_error;
std::filesystem::create_symlink(root / "Outside/secret.cpp",
root / "Scripts/escape.cpp", link_error);
if (!link_error)
reject_command("faset_source_open", {{"path", "Scripts/escape.cpp"}}, "source.path");
auto bad_lua_project = session.project();
bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}};
atomic_write_json(root / "project.faset.json", bad_lua_project);
@@ -206,24 +122,6 @@ int test_main(int argc, char** argv) {
require(invalid_lua_status.at("stale") == true &&
!invalid_lua_status.at("error").get<std::string>().empty(),
"test", "Invalid Lua manifest did not mark schema stale with diagnostics");
const auto autosave_id = document.at("id").get<std::string>();
const auto autosave_revision =
session.authoring().query(autosave_id).at("revision").get<std::uint64_t>();
session.authoring().transact(
autosave_id, autosave_revision,
Json::array({{{"op", "scene.rename"}, {"name", "Saved by MCP polling"}}}));
reject_command("faset_document_save",
{{"document", autosave_id},
{"expected_revision", autosave_revision}},
"revision.conflict");
session.poll();
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true,
"test", "Autosave status is available through Editor commands");
std::this_thread::sleep_for(std::chrono::milliseconds(2100));
session.poll();
require(read_json(root / path_from_utf8("Scenes/Начало 世界.scene.json")).at("name") ==
"Saved by MCP polling",
"test", "A long-lived headless Editor poll saves a named scene after idle");
external["version"] = 999;
atomic_write_json(root / "project.faset.json", external);
bool rejected = false;
@@ -238,16 +136,7 @@ int test_main(int argc, char** argv) {
"configuration preservation and Lua editor commands passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << "\nFixture retained at " << path_to_utf8(root) << '\n';
std::cerr << error.what() << "\nFixture retained at " << root << '\n';
return 1;
}
}
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) {
return faset::run_utf8_main(argc, argv, test_main);
}
#else
int main(int argc, char** argv) {
return test_main(argc, argv);
}
#endif
-70
View File
@@ -1,70 +0,0 @@
#include <chrono>
#include <cmath>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
#include <stdexcept>
int main(int argc, char** argv) {
using namespace faset;
using Clock = std::chrono::steady_clock;
if (argc != 2) {
std::cerr << "Usage: faset_editor_ui_latency OUTPUT.json\n";
return 2;
}
const auto root = std::filesystem::temp_directory_path() /
path_from_utf8("faset-ui-latency-" + new_id());
try {
std::filesystem::create_directories(root);
atomic_write_json(root / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"name", "UI latency fixture"},
{"dimension", 3}});
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
render::Renderer renderer({1280, 800, "Editor input latency", true, true});
editor::EditorUI ui(session, renderer,
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
ui.frame({});
Json samples = Json::array();
constexpr int warmup = 10, measured = 100;
for (int index = 0; index < warmup + measured; ++index) {
render::Event event;
event.type = render::Event::Type::KeyDown;
event.key = index % 2 == 0 ? "E" : "W";
const auto started = Clock::now();
ui.frame({event});
renderer.render(ui.snapshot());
const auto elapsed = std::chrono::duration<double, std::milli>(Clock::now() - started)
.count();
const auto target = index % 2 == 0 ? "gizmo-Rotate" : "gizmo-Move";
if (!ui.widgets().find(target)->selected ||
renderer.stats().validation_errors != 0 || !std::isfinite(elapsed) ||
elapsed <= 0)
throw std::runtime_error("Synthetic input was not visible in a valid frame");
if (index >= warmup)
samples.push_back({{"iteration", index - warmup + 1},
{"milliseconds", elapsed},
{"gizmo", event.key == "E" ? "Rotate" : "Move"}});
}
atomic_write_json(path_from_utf8(argv[1]),
{{"format", "faset.editor-ui-latency"},
{"version", 1},
{"warmup_frames", warmup},
{"measured_frames", measured},
{"presentation_mode", "offscreen"},
{"method", "KeyDown gizmo toggle through EditorUI::frame and "
"offscreen Vulkan render completion; no window present"},
{"device", renderer.stats().device},
{"validation_enabled", renderer.stats().validation_enabled},
{"validation_errors", renderer.stats().validation_errors},
{"samples", samples}});
std::filesystem::remove_all(root);
std::cout << "Measured " << measured << " synthetic Editor input frames\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << "\nFixture: " << path_to_utf8(root) << '\n';
return 1;
}
}
-17
View File
@@ -111,9 +111,6 @@ 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)});
@@ -121,7 +118,6 @@ 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,
@@ -131,19 +127,6 @@ 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);
+2 -71
View File
@@ -1,8 +1,6 @@
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace faset;
namespace {
void check(bool value, const std::string& message) {
@@ -52,7 +50,6 @@ int main() {
{"id", new_id()},
{"name", "Original project"},
{"dimension", 3},
{"editor", {{"script_editor", {"zed", "{file}"}}}},
{"custom_metadata", {{"preserve", true}}}});
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
render::Renderer renderer({1280, 800, "Project settings acceptance", true, true});
@@ -76,13 +73,6 @@ int main() {
open(ui);
check(ui.widgets().find("project-settings-name")->text == "Original project",
"Opening settings reloads current saved values");
check(ui.widgets().find("project-settings-autosave")->checked,
"Missing autosave setting appears enabled in Project settings");
check(ui.widgets().focus("project-settings-autosave"),
"Keyboard focus reaches the Autosave toggle");
ui.frame({key("Space")});
check(!ui.widgets().find("project-settings-autosave")->checked,
"Space toggles Autosave from the keyboard");
text(ui, "project-settings-name", "Новый проект");
click(ui, "project-settings-2d");
click(ui, "project-scene-choice-1");
@@ -91,25 +81,11 @@ int main() {
click(ui, "project-settings-save");
auto saved = read_json(project_file);
check(saved["name"] == "Новый проект" && saved["dimension"] == 2 &&
saved["start_scene"] == "Scenes/Другая.scene.json" &&
saved["editor"]["autosave"] == false,
saved["start_scene"] == "Scenes/Другая.scene.json",
"Explicit Save project persists typed settings");
check(saved["custom_metadata"] == project_before["custom_metadata"] &&
saved["id"] == project_before["id"] &&
saved["editor"]["script_editor"] ==
project_before["editor"]["script_editor"],
saved["id"] == project_before["id"],
"Project settings preserve unknown metadata and project identity");
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Autosave off",
"Disabling Autosave is visible in the Editor status bar");
open(ui);
check(!ui.widgets().find("project-settings-autosave")->checked,
"Autosave preference is restored when reopening Project settings");
click(ui, "project-settings-autosave");
click(ui, "project-settings-save");
saved = read_json(project_file);
check(saved["editor"]["autosave"] == true,
"Autosave can be enabled again through Project settings");
check(session.authoring().query(ui.current_document()) == document_before,
"Project settings do not change current document, dimension, revision or Undo");
open(ui);
@@ -142,51 +118,6 @@ int main() {
"Save after explicit reload uses new content revision");
check(session.authoring().query(ui.current_document()) == document_before,
"Project settings remain outside scene Undo throughout conflicts");
const auto original_document = ui.current_document();
const auto revision =
session.authoring().query(original_document).at("revision").get<std::uint64_t>();
session.authoring().transact(
original_document, revision,
Json::array({{{"op", "scene.rename"}, {"name", "Pending autosave"}}}));
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Pending autosave",
"Status bar shows pending scene save after edit");
session.authoring().save(original_document);
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Saved",
"Status bar returns to Saved after the scene reaches disk");
session.authoring().transact(
original_document,
session.authoring().query(original_document).at("revision"),
Json::array({{{"op", "scene.rename"}, {"name", "Conflicting autosave"}}}));
ui.frame({});
auto disk_scene = read_json(root / "Scenes/Главная.scene.json");
disk_scene["name"] = "External scene edit";
atomic_write_json(root / "Scenes/Главная.scene.json", disk_scene);
std::this_thread::sleep_for(std::chrono::milliseconds(2100));
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Save conflict" &&
ui.widgets().find("autosave-status")->tooltip.find("Save As") !=
std::string::npos,
"Disk conflict remains visible with Save As guidance");
renderer.render(ui.snapshot());
renderer.capture(root / "autosave-conflict.ppm");
check(ui.widgets().focus("autosave-save-as"),
"Keyboard focus reaches the recovery Save As action");
ui.frame({key("Return")});
check(ui.widgets().find("menu-popup")->visible &&
ui.widgets().find("save-path")->visible,
"Recovery action opens an editable Save As path");
ui.frame({key("Escape")});
const auto unnamed = session.authoring().create("Unsaved", 2);
session.authoring().transact(
unnamed.at("id"), unnamed.at("revision"),
Json::array({{{"op", "scene.rename"}, {"name", "Needs a path"}}}));
ui.select_document(unnamed.at("id"));
ui.frame({});
check(ui.widgets().find("autosave-status")->text == "Save As required" &&
ui.widgets().find("autosave-save-as")->visible,
"Unnamed dirty scene is never assigned an implicit path");
open(ui);
renderer.render(ui.snapshot());
renderer.capture(root / "project-settings.ppm");
-80
View File
@@ -1,80 +0,0 @@
"""Contracts for reproducible, honestly labelled P1 workflow measurements."""
import importlib.util
from pathlib import Path
import unittest
MODULE = Path(__file__).resolve().parents[1] / "tools/measure_workflows.py"
SPEC = importlib.util.spec_from_file_location("measure_workflows", MODULE)
workflows = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(workflows)
def report(samples):
return {
"format": "faset.workflow-measurements",
"version": 2,
"revision": "0123456789abcdef",
"source_hashes": {"Scripts/Gameplay.cpp": "a" * 64},
"build_configuration": "Debug",
"samples": samples,
}
class WorkflowReportTests(unittest.TestCase):
def test_nearest_rank_summary(self):
self.assertEqual(workflows.summarize([1, 2, 3, 4, 5]),
{"median": 3, "p95": 5})
def test_summary_rejects_empty_and_invalid_samples(self):
for values in ([], [0, -1], [1, float("nan")]):
with self.subTest(values=values), self.assertRaises(ValueError):
workflows.summarize(values)
def test_report_requires_revision_hashes_and_repetitions(self):
samples = {"warm_unchanged_build": [
{"seconds": 1.0, "build_configuration": "Debug", "source_hash": "a" * 64}
for _ in range(5)]}
workflows.validate_report(report(samples))
for mutation in (
lambda value: value.update(revision=""),
lambda value: value.update(source_hashes={}),
lambda value: value["samples"]["warm_unchanged_build"].pop(),
lambda value: value["samples"]["warm_unchanged_build"][0].update(
build_configuration="Release"),
):
value = report({"warm_unchanged_build": [dict(sample) for sample in samples["warm_unchanged_build"]]})
mutation(value)
with self.subTest(value=value), self.assertRaises(ValueError):
workflows.validate_report(value)
def test_windowed_claim_rejects_offscreen_profile(self):
value = report({"windowed_first_presented_frame": [{
"seconds": 0.1, "build_configuration": "Debug",
"source_hash": "a" * 64, "presentation_mode": "offscreen"}]})
with self.assertRaises(ValueError):
workflows.validate_report(value)
def test_generated_scene_is_seeded_and_preserves_source(self):
scene = {"format": "faset.scene", "version": 1, "entities": [{
"id": "reference", "name": "Reference", "parent": None,
"components": [{"id": "transform", "type": "faset.transform", "version": 1,
"fields": {"position": [0, 0, 0]}}]}]}
first = workflows.generate_scene_fixture(scene, seed=17, count=8)
second = workflows.generate_scene_fixture(scene, seed=17, count=8)
self.assertEqual(first, second)
self.assertEqual(len(first["entities"]), 9)
self.assertEqual(scene["entities"][0]["id"], "reference")
self.assertEqual(len({entry["id"] for entry in first["entities"]}), 9)
self.assertNotEqual(first, workflows.generate_scene_fixture(scene, seed=18, count=8))
def test_gnu_time_keeps_rss_after_expected_command_failure(self):
self.assertEqual(workflows.parse_peak_rss(
'Command exited with non-zero status 1\n{"peak_rss_kib":353272}\n'), 353272)
with self.assertRaises(ValueError):
workflows.parse_peak_rss("Command exited with non-zero status 1\n")
if __name__ == "__main__":
unittest.main()
@@ -59,6 +59,15 @@ int main() {
faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"A consistently rehashed but incompatible GPU record stride must be rejected");
faset::atomic_write_json(reflection_file,
faset::read_json(original / "gpuPostCullMain.reflection.json"));
reflection_file = temporary / "gpuVertexMain.reflection.json";
metadata = faset::read_json(original / "gpuVertexMain.reflection.json");
metadata["layout"]["descriptors"][0]["set"] = 1;
metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump());
faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"GPU graphics scene buffers must stay in descriptor set two");
fs::remove(temporary / "gpuHzbMain.spv");
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"Missing P2 entry must be rejected");
+191
View File
@@ -0,0 +1,191 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace faset::render;
namespace {
void require(bool condition, const char* message) {
if (!condition)
throw std::runtime_error(message);
}
Snapshot fixture() {
Snapshot frame;
const Vec3 eye{0, 2, 8};
const auto view = look_at(eye, {0, 0, 0});
const auto projection = perspective(.9f, 16.f / 9.f, .1f, 120.f);
frame.eye = eye;
frame.view_projection = multiply(projection, view);
frame.projection = projection;
frame.camera_frustum = CameraFrustum{view, projection, .1f, 120.f, true};
frame.authored_lights_present = true;
frame.sun = SunLight{"sun", {-.7f, -.5f, -.3f}, {1, 1, 1, 1}, 1, true};
return frame;
}
LocalLight point_light(std::string id, int priority = 0) {
LocalLight light;
light.kind = LocalLight::Kind::Point;
light.stable_id = std::move(id);
light.position = {0, 2, 0};
light.range = 12;
light.shadow_priority = priority;
return light;
}
LocalLight spot_light(std::string id, int priority = 0) {
auto light = point_light(std::move(id), priority);
light.kind = LocalLight::Kind::Spot;
light.direction = {0, -1, 0};
return light;
}
bool contains_caster(const ShadowView& view, std::uint32_t draw_index) {
return std::find(view.caster_indices.begin(), view.caster_indices.end(), draw_index) !=
view.caster_indices.end();
}
void run() {
auto frame = fixture();
const auto plan = build_shadow_plan(frame, {}, {});
require(plan.sun_views.size() == 4 && plan.effective_sun_cascades == 4,
"Explicit camera receives four usable sun cascades");
require(plan.sun_views[0].split_near == .1f &&
std::abs(plan.sun_views.back().split_far - 80.f) < 1e-4f,
"Practical splits start at camera near and stop at shadow distance");
for (std::size_t i = 1; i < plan.sun_views.size(); ++i)
require(plan.sun_views[i].split_near == plan.sun_views[i - 1].split_far &&
plan.sun_views[i].split_far > plan.sun_views[i].split_near,
"Cascade split endpoints are strictly increasing and contiguous");
require(plan.sun_views[0].tile_index == 0 && plan.sun_views[3].tile_index == 3 &&
plan.sun_views[0].usable_size == 1020,
"Four guarded 1024-square tiles fit a 2048-square sun atlas");
auto shifted = frame;
shifted.eye[0] += .00001f;
const auto shifted_view = look_at(shifted.eye, {.00001f, 0, 0});
shifted.camera_frustum->view = shifted_view;
shifted.view_projection = multiply(shifted.projection, shifted_view);
const auto stable = build_shadow_plan(shifted, {}, {});
require(stable.sun_views[0].snapped_center_x == plan.sun_views[0].snapped_center_x &&
stable.sun_views[0].snapped_center_y == plan.sun_views[0].snapped_center_y,
"Subtexel camera translation retains the snapped sun projection origin");
const auto sun_direction = frame.sun->direction;
const auto inv_length = 1.f / std::hypot(sun_direction[0], sun_direction[1], sun_direction[2]);
const Vec3 upstream{-sun_direction[0] * inv_length * 18,
-sun_direction[1] * inv_length * 18,
-sun_direction[2] * inv_length * 18};
const ShadowCasterBounds offscreen{{{upstream[0] - .5f, upstream[1] - .5f,
upstream[2] - .5f},
{upstream[0] + .5f, upstream[1] + .5f,
upstream[2] + .5f}}, 7};
const ShadowCasterBounds outside{{{999, 0, 0}, {1001, 2, 2}}, 8};
const std::array casters{offscreen, outside};
const auto with_casters = build_shadow_plan(frame, casters, {});
require(std::any_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 7); }),
"Offscreen upstream caster remains in a receiver's sun shadow view");
require(std::none_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 8); }),
"Caster outside every sun XY footprint is excluded");
std::vector<ShadowCasterBounds> many(4097, {{{-.1f, -.1f, -.1f}, {.1f, .1f, .1f}}, 0});
for (std::uint32_t i = 0; i < many.size(); ++i)
many[i].draw_index = i;
const auto overdraw = build_shadow_plan(frame, many, {});
require(overdraw.caster_draws <= 4096 &&
std::any_of(overdraw.sun_views.begin(), overdraw.sun_views.end(),
[](const auto& view) {
return !view.valid && view.reason == ShadowDropReason::CasterBudget &&
view.caster_indices.empty();
}),
"A view with 4097 casters is skipped whole rather than partially rendered");
frame.sun.reset();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
const auto capacity = build_shadow_plan(frame, {}, {});
require(capacity.local_faces_used == 15 && capacity.dropped_point_faces == 6 &&
capacity.local_faces_used <= 16 && capacity.caster_draws <= 4096,
"Insufficient room for six point faces drops the complete point shadow");
require(capacity.submitted_local_indices.size() == 16 &&
std::none_of(capacity.local_views.begin(), capacity.local_views.end(),
[](const auto& view) { return view.light_id == "last-point"; }),
"Atlas overflow leaves the point light in the lighting list, unshadowed");
frame.local_lights = {point_light("omnidirectional")};
const ShadowCasterBounds positive_x{{{3, -.2f, -.2f}, {3.4f, .2f, .2f}}, 19};
const auto point_faces = build_shadow_plan(frame, std::array{positive_x}, {});
require(point_faces.local_views.size() == 6 &&
contains_caster(point_faces.local_views[0], 19) &&
!contains_caster(point_faces.local_views[1], 19),
"Point-light caster behind the opposite face is culled from that face");
frame.local_lights.clear();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
auto reversed = frame;
std::reverse(reversed.local_lights.begin(), reversed.local_lights.end());
const auto reordered = build_shadow_plan(reversed, {}, {});
require(reordered.local_views.size() == capacity.local_views.size(),
"Reversing input lights preserves scheduled view count");
for (std::size_t i = 0; i < capacity.local_views.size(); ++i)
require(reordered.local_views[i].light_id == capacity.local_views[i].light_id &&
reordered.local_views[i].tile_index == capacity.local_views[i].tile_index,
"Stable IDs preserve atlas assignments across input reordering");
frame.local_lights.clear();
for (int i = 0; i < 128; ++i) {
auto light = spot_light("ordinary-" + std::to_string(i));
light.casts_shadow = false;
frame.local_lights.push_back(light);
}
auto important = spot_light("late-high-priority", 5);
important.casts_shadow = false;
frame.local_lights.push_back(important);
const auto ranked = build_shadow_plan(frame, {}, {});
require(ranked.submitted_local_indices.size() == 128 &&
ranked.omitted_local_lights == 1 &&
ranked.submitted_local_indices.front() == 128,
"Submission selects all 128 by priority and reports one omitted light");
frame.local_lights.back().range = -1;
bool invalid_overflow_rejected = false;
try {
(void)build_shadow_plan(frame, {}, {});
} catch (const std::invalid_argument&) {
invalid_overflow_rejected = true;
}
require(invalid_overflow_rejected,
"All authored lights are validated even when beyond the submission cap");
frame.local_lights.clear();
frame.sun.reset();
const auto no_sun = build_shadow_plan(frame, {}, {});
require(no_sun.requested_sun_cascades == 0 && no_sun.sun_views.empty(),
"Authored lights suppress legacy sun even when none is enabled");
for (int i = 0; i < 20; ++i)
frame.local_lights.push_back(spot_light("over-cap-" + std::to_string(i)));
ShadowBudget relaxed;
relaxed.max_local_faces = 64;
relaxed.max_local_lights = 256;
const auto clamped = build_shadow_plan(frame, {}, relaxed);
require(clamped.local_faces_used == 16 && clamped.dropped_local_faces == 4,
"Fixed 4x4 local atlas never allocates outside its sixteen tiles");
frame = fixture();
frame.camera_frustum.reset();
const auto legacy = build_shadow_plan(frame, {}, {});
require(legacy.sun_views.size() == 1 && legacy.effective_sun_cascades == 1,
"A low-level snapshot without camera frustum uses one reported sun view");
frame = fixture();
ShadowBudget unsupported;
unsupported.sun_atlas_available = false;
const auto no_atlas = build_shadow_plan(frame, {}, unsupported);
require(no_atlas.effective_sun_cascades == 0 &&
no_atlas.dropped_sun_views == 4 &&
no_atlas.sun_views[0].reason == ShadowDropReason::Unavailable,
"Unsupported depth atlas yields an explicit unshadowed sun fallback");
}
} // namespace
int main() {
try {
run();
std::cout << "Shadow planning, stable allocation, caster visibility, and budgets passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+35 -2
View File
@@ -59,6 +59,27 @@ int main() {
const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json");
const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint");
render::validate_shader_bundle(bundle);
auto bad_lighting_stride = Json::parse(original_reflection);
auto& lighting_descriptors = bad_lighting_stride["layout"]["descriptors"];
bool found_local_buffer = false;
for (auto& descriptor : lighting_descriptors)
if (descriptor["set"] == 1 && descriptor["binding"] == 1) {
descriptor["element_stride"] = 96;
found_local_buffer = true;
}
require(found_local_buffer, "Lighting stride fixture exists");
bad_lighting_stride["layout_fingerprint"] =
sha256(bad_lighting_stride["layout"].dump());
atomic_write_json(bundle / "fragmentMain.reflection.json", bad_lighting_stride);
bool rejected_lighting_stride = false;
try {
render::validate_shader_bundle(bundle);
} catch (const std::exception&) {
rejected_lighting_stride = true;
}
require(rejected_lighting_stride,
"Rehashed incompatible local-light element stride must be rejected");
atomic_write(bundle / "fragmentMain.reflection.json", original_reflection);
render::RendererConfig configuration;
configuration.width = configuration.height = 64;
configuration.headless = true;
@@ -161,6 +182,18 @@ int main() {
atomic_write(bundle / "fragmentMain.spv", "damaged bytecode");
retained();
restore();
auto incompatible = original_source;
auto at = incompatible.find(" float4 reserved;");
require(at != std::string::npos, "Local-light stride fixture exists");
incompatible.replace(at, std::string(" float4 reserved;").size(),
" float4 reserved;\n float4 incompatibleExtraLane;");
atomic_write(source, incompatible);
require(compile(source, bundle) == 0, "Compile incompatible light-buffer stride");
require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") !=
original_fingerprint,
"Lighting stride edit changes normalized layout fingerprint");
retained();
restore();
auto malformed = original_spirv;
for (int i = 0; i < 4; ++i)
malformed[20 + i] = 0; // zero-word SPIR-V instruction
@@ -170,8 +203,8 @@ int main() {
atomic_write_json(bundle / "fragmentMain.reflection.json", metadata);
retained();
restore();
auto incompatible = original_source;
auto at = incompatible.find("[[vk::binding(2,0)]]");
incompatible = original_source;
at = incompatible.find("[[vk::binding(2,0)]]");
require(at != std::string::npos, "Shader descriptor fixture exists");
incompatible.replace(at, std::string("[[vk::binding(2,0)]]").size(),
"[[vk::binding(7,0)]]");
+96
View File
@@ -134,6 +134,102 @@ int main(int argc, char** argv) {
renderer.render(scene);
pixels = renderer.pixels();
require(pixels[index + 2] > 220, "Texture revision upload");
Snapshot two_lights;
two_lights.eye = {0, 0, 6};
two_lights.projection = perspective(.85f, 320.f / 240.f, .1f, 30.f);
two_lights.view_projection =
multiply(two_lights.projection, look_at(two_lights.eye, {0, 0, 0}));
two_lights.authored_lights_present = true;
two_lights.draws.push_back(
{cube_mesh(), transform({-1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false});
two_lights.draws.back().instance_key = "left-light-receiver";
two_lights.draws.push_back(
{cube_mesh(), transform({1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false});
two_lights.draws.back().instance_key = "right-light-receiver";
two_lights.ui_quads.push_back({8, 8, 40, 20, {.8f, .1f, .15f, 1}});
for (auto mode : {VisibilityMode::Direct, VisibilityMode::GpuFrustum}) {
renderer.set_visibility_mode(mode);
renderer.render(two_lights);
const auto dark = renderer.pixels();
require(renderer.stats().validation_errors == 0,
"Zero-local-light descriptors are initialized");
auto legacy_lights = two_lights;
legacy_lights.authored_lights_present = false;
renderer.render(legacy_lights);
const auto legacy = renderer.pixels();
const auto left = (120 * 320 + 99) * 4;
require(legacy[left] > dark[left] + 15,
"Authored-light presence suppresses the legacy sun even without a local light");
two_lights.local_lights = {
{LocalLight::Kind::Point, "red", {-1.4f, 0, 1.4f}, {0, 0, -1},
{1, 0, 0, 1}, 8, 2.2f, .35f, .7f, false, 0},
{LocalLight::Kind::Point, "blue", {1.4f, 0, 1.4f}, {0, 0, -1},
{0, 0, 1, 1}, 8, 2.5f, .35f, .7f, false, 0}};
renderer.render(two_lights);
const auto lit = renderer.pixels();
const auto right = (120 * 320 + 221) * 4;
const auto ui = (10 * 320 + 10) * 4;
require(lit[left] > dark[left] + 20 && lit[right + 2] > dark[right + 2] + 20,
"Separated red and blue point lights illuminate their receivers");
require(std::abs(int(lit[left + 2]) - int(dark[left + 2])) < 6 &&
std::abs(int(lit[right]) - int(dark[right])) < 6,
"Local light range keeps the opposite colored light off each receiver");
for (int channel = 0; channel < 4; ++channel)
require(lit[ui + channel] == dark[ui + channel],
"Lighting changes leave UI tint unchanged");
require(renderer.stats().validation_errors == 0,
"Direct and GPU local lighting report no Vulkan errors");
if (mode == VisibilityMode::GpuFrustum)
require(renderer.stats().effective_visibility_mode == VisibilityMode::GpuFrustum,
"Local light image test actually exercises the GPU visibility path");
two_lights.local_lights[1].kind = LocalLight::Kind::Spot;
renderer.render(two_lights);
const auto aimed = renderer.pixels();
two_lights.local_lights[1].direction = {1, 0, 0};
renderer.render(two_lights);
const auto turned = renderer.pixels();
require(aimed[right + 2] > turned[right + 2] + 20,
"Spotlight cone direction changes receiver illumination");
two_lights.local_lights.clear();
}
renderer.set_visibility_mode(VisibilityMode::Direct);
renderer.render(two_lights);
const auto unlit_overflow = renderer.pixels();
for (int i = 0; i < 128; ++i) {
LocalLight local;
local.stable_id = "low-priority-" + std::to_string(i);
local.position = {20, 20, 20};
local.range = 1;
local.intensity = 0;
local.casts_shadow = false;
two_lights.local_lights.push_back(local);
}
LocalLight high;
high.stable_id = "last-high-priority";
high.position = {-1.4f, 0, 1.4f};
high.color = {1, 0, 0, 1};
high.range = 2.2f;
high.intensity = 8;
high.shadow_priority = 10;
high.casts_shadow = false;
two_lights.local_lights.push_back(high);
two_lights.local_lights.back().range = -1;
bool overflow_validation_failed = false;
try {
renderer.render(two_lights);
} catch (const std::invalid_argument&) {
overflow_validation_failed = true;
}
require(overflow_validation_failed,
"Renderer validates light records beyond the 128-light cap");
two_lights.local_lights.back().range = 2.2f;
renderer.render(two_lights);
const auto ranked_pixels = renderer.pixels();
const auto ranked_left = (120 * 320 + 99) * 4;
require(renderer.stats().submitted_local_lights == 128 &&
renderer.stats().omitted_local_lights == 1 &&
ranked_pixels[ranked_left] > unlit_overflow[ranked_left] + 20,
"High-priority last light is submitted and omitted count is observable");
if (argc > 2)
renderer.capture(argv[2]);
renderer.resize(400, 300);
+128
View File
@@ -1,10 +1,12 @@
#include <bit>
#include <algorithm>
#include <cmath>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp>
#include <iostream>
#include <limits>
#include <numbers>
#include <stdexcept>
@@ -23,12 +25,137 @@ template <class F> void rejects(F&& function, const char* message) {
}
check(caught, message);
}
template <class F>
void rejectsContaining(F&& function, std::string_view entityId, std::string_view field) {
try {
function();
} catch (const std::exception& error) {
const std::string_view what(error.what());
check(what.find(entityId) != std::string_view::npos &&
what.find(field) != std::string_view::npos,
"Invalid light reports entity and field");
return;
}
throw std::runtime_error("Invalid light was accepted");
}
Json component(std::string type, Json fields) {
return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}};
}
Json entity(std::string id, Json parent, Json components) {
return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}};
}
void lightingExtraction(faset::player::SceneView& view) {
auto scene = Json{{"format", "faset.scene"},
{"version", 1},
{"id", "lighting"},
{"name", "Lighting"},
{"dimension", 3},
{"instances", Json::array()},
{"entities", Json::array()}};
const auto empty = view.build(scene, 16.f / 9.f);
check(!empty.authored_lights_present && !empty.sun && empty.local_lights.empty(),
"Scene with no lights leaves legacy sun fallback available");
check(empty.camera_frustum && empty.camera_frustum->perspective &&
empty.camera_frustum->near_plane > 0 &&
empty.camera_frustum->far_plane > empty.camera_frustum->near_plane &&
faset::render::multiply(empty.camera_frustum->projection,
empty.camera_frustum->view) == empty.view_projection,
"3D extraction retains an unjittered camera frustum");
scene["entities"] = Json::array({
entity("sun", nullptr,
Json::array({component("faset.transform", {{"rotation", {0, .4, 0}}}),
component("faset.light", {{"kind", "directional"},
{"color", {1, .8, .6, 1}},
{"intensity", 2.5},
{"casts_shadow", false}})})),
entity("point", nullptr,
Json::array({component("faset.transform", {{"position", {2, 3, 4}}}),
component("faset.light", {{"kind", "point"},
{"color", {1, 0, 0, 1}},
{"intensity", 4},
{"range", 6},
{"shadow_priority", 3}})})),
entity("spot", nullptr,
Json::array({component("faset.transform", {{"position", {-2, 1, 0}}}),
component("faset.light", {{"kind", "spot"},
{"color", {0, 0, 1, 1}},
{"intensity", 3},
{"range", 8},
{"inner_angle", .2},
{"outer_angle", .6}})}))});
const auto a = view.build(scene, 16.f / 9.f);
check(a.authored_lights_present && a.sun && a.local_lights.size() == 2,
"Directional, point, and spot lights survive extraction");
check(a.sun->color[1] == .8f && a.sun->intensity == 2.5f && !a.sun->casts_shadow,
"Authored sun properties survive extraction");
check(a.local_lights[0].kind == faset::render::LocalLight::Kind::Point &&
a.local_lights[0].position == faset::render::Vec3{2, 3, 4} &&
a.local_lights[0].range == 6 && a.local_lights[0].shadow_priority == 3,
"Point fields and transform survive extraction");
check(a.local_lights[1].kind == faset::render::LocalLight::Kind::Spot &&
a.local_lights[1].inner_angle == .2f && a.local_lights[1].outer_angle == .6f,
"Spot cone survives extraction");
std::reverse(scene["entities"].begin(), scene["entities"].end());
const auto b = view.build(scene, 16.f / 9.f);
check(a.sun->stable_id == b.sun->stable_id &&
a.local_lights[0].stable_id == b.local_lights[0].stable_id &&
a.local_lights[1].stable_id == b.local_lights[1].stable_id,
"Light identity and ordering ignore entity array order");
scene["entities"].erase(scene["entities"].begin() + 2);
const auto localOnly = view.build(scene, 1);
check(localOnly.authored_lights_present && !localOnly.sun &&
localOnly.local_lights.size() == 2,
"Local-only lighting does not synthesize a sun");
scene["entities"] = Json::array({entity(
"disabled-sun", nullptr,
Json::array({component("faset.light", {{"kind", "directional"}, {"enabled", false}})}))});
const auto disabled = view.build(scene, 1);
check(disabled.authored_lights_present && !disabled.sun && disabled.local_lights.empty(),
"Explicit disabled sun suppresses legacy fallback");
scene["entities"][0]["components"][0]["version"] = 2;
const auto future = view.build(scene, 1);
check(future.authored_lights_present && !future.sun,
"Opaque future-version light still suppresses legacy fallback");
scene["entities"] = Json::array({
entity("sun-z", nullptr, Json::array({component("faset.light", Json::object())})),
entity("sun-a", nullptr, Json::array({component("faset.light", Json::object())}))});
const auto twoSuns = view.build(scene, 1);
check(twoSuns.sun && twoSuns.sun->stable_id.find("sun-a") != std::string::npos,
"Multiple suns select lowest stable identity");
check(!view.diagnostics().empty() &&
view.diagnostics().front().find("directional") != std::string::npos,
"Additional directionals produce an actionable diagnostic");
scene["entities"] = Json::array({entity(
"bad-light", nullptr,
Json::array({component("faset.light", {{"kind", "point"}, {"range", 0}})}))});
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "range");
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "spot"}, {"range", 10}, {"inner_angle", .8}, {"outer_angle", .2}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "inner_angle");
scene["entities"][0]["components"][0]["fields"] = {{"kind", "area"}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "kind");
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "point"}, {"shadow_priority", std::int64_t{2147483648}}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "shadow_priority");
scene["entities"][0]["components"][0]["fields"] = {{"kind", "point"}};
scene["entities"][0]["components"].insert(
scene["entities"][0]["components"].begin(),
component("faset.transform", {{"scale", {1, 1, 0}}}));
const auto flatPoint = view.build(scene, 1);
check(flatPoint.local_lights.size() == 1 &&
flatPoint.local_lights[0].kind == faset::render::LocalLight::Kind::Point,
"Point light accepts a zero Z scale because it needs only a position");
scene["entities"][0]["components"].erase(scene["entities"][0]["components"].begin());
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "point"},
{"color", Json::array({1, std::numeric_limits<double>::quiet_NaN(), 1, 1})}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "color");
scene["entities"][0]["components"].insert(
scene["entities"][0]["components"].begin(),
component("faset.transform", {{"position", {0, 0, 0}},
{"scale", Json::array({1, std::numeric_limits<double>::quiet_NaN(), 1})}}));
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "transform");
}
void physicsDebug(faset::player::SceneView& view) {
for (int dimension : {2, 3}) {
const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d";
@@ -159,6 +286,7 @@ void run() {
faset::atomic_write(folder / "version.fscene", version);
rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version");
faset::player::SceneView view(folder);
lightingExtraction(view);
physicsDebug(view);
auto snapshot = view.build(scene, 16.f / 9.f);
check(snapshot.draws.size() == 1, "SceneView builtin mesh");
+229
View File
@@ -0,0 +1,229 @@
"""Contract and arithmetic tests for the offline P3 lighting sweep wrapper."""
import csv
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "tools/benchmark_p3_lighting.py"
sys.path.insert(0, str(ROOT / "tools"))
def sample(shadows, visibility, lights, raster, gpu, repeat=1, frame=0):
return {
"shadows": shadows, "visibility": visibility, "light_count": str(lights),
"run_index": str(repeat), "frame": str(frame),
"gpu_main_raster_ms": str(raster), "gpu_ms": str(gpu),
"gpu_shadow_ms": "0", "cpu_ms": "1", "readback_cpu_ms": ".5",
"validation_errors": "0", "device": "Fake GPU", "driver": "Fake Driver",
"commit": "abc123", "effective_visibility": visibility,
"lighting_path": "forward", "submitted_local_lights": str(lights),
"omitted_local_lights": "0", "shadow_tiles": "0", "draw_calls": "1",
"gpu_bytes": "4096", "validation_enabled": "0", "width": "1920", "height": "1080",
}
FAKE_BENCHMARK = r'''import argparse
import csv
import json
from pathlib import Path
p = argparse.ArgumentParser()
for flag in ("lights", "run-index", "width", "height", "warmup", "frames"):
p.add_argument("--" + flag, type=int, required=True)
for flag in ("shadows", "visibility", "csv", "commit", "validation"):
p.add_argument("--" + flag, required=True)
p.add_argument("--driver")
a = p.parse_args()
path = Path(a.csv)
path.with_suffix(".args.json").write_text(json.dumps(vars(a)), encoding="utf-8")
fieldnames = ["light_count", "shadows", "visibility", "frame", "device", "driver",
"commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
"readback_cpu_ms", "validation_errors", "run_index", "effective_visibility",
"lighting_path", "submitted_local_lights", "omitted_local_lights",
"shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height"]
with path.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=fieldnames)
writer.writeheader()
for frame in range(a.frames):
writer.writerow(dict(light_count=a.lights, shadows=a.shadows,
visibility=a.visibility, frame=frame, device="Fake GPU",
driver="Fake Driver", commit=a.commit,
gpu_main_raster_ms=.4 + .02 * a.lights, gpu_ms=4 + .02 * a.lights,
gpu_shadow_ms=0, cpu_ms=1, readback_cpu_ms=.5,
validation_errors=0, run_index=a.run_index,
effective_visibility=a.visibility, lighting_path="forward",
submitted_local_lights=a.lights, omitted_local_lights=0,
shadow_tiles=0, draw_calls=1, gpu_bytes=4096,
validation_enabled=0, width=a.width, height=a.height))
'''
class LightingBenchmarkTests(unittest.TestCase):
def test_list_runs_has_three_independent_repeats_for_each_shadow_setting(self):
process = subprocess.run([sys.executable, SCRIPT, "--list-runs"],
text=True, capture_output=True, check=True)
runs = json.loads(process.stdout)["runs"]
self.assertEqual(len(runs), 108)
for shadows in ("off", "on"):
group = [run for run in runs if run["shadows"] == shadows]
self.assertEqual(len(group), 54)
self.assertEqual({(run["light_count"], run["visibility"])
for run in group},
{(light, mode) for light in (0, 4, 16, 32, 64, 128)
for mode in ("direct", "gpu-frustum", "gpu-occlusion")})
self.assertEqual({(run["light_count"], run["visibility"], run["repeat"])
for run in group},
{(light, mode, repeat)
for light in (0, 4, 16, 32, 64, 128)
for mode in ("direct", "gpu-frustum", "gpu-occlusion")
for repeat in (1, 2, 3)})
def test_gate_uses_per_run_medians_and_same_mode_shadow_baseline(self):
from benchmark_p3_lighting import summarize_rows
rows = []
for repeat in (1, 2, 3):
for frame in (0, 1, 2):
rows.append(sample("off", "direct", 0, .5, 10, repeat, frame))
rows.append(sample("off", "direct", 32,
100 if repeat == 3 else 1.5, 11, repeat, frame))
rows.append(sample("on", "gpu-frustum", 0, .5, 4, repeat, frame))
rows.append(sample("on", "gpu-frustum", 64, 1.1, 4.6, repeat, frame))
rows.append(sample("off", "gpu-occlusion", 0, .5, 4, repeat, frame))
rows.append(sample("off", "gpu-occlusion", 128, 1.09, 4.59, repeat, frame))
summary = summarize_rows(rows)
hits = {(item["shadows"], item["visibility"], item["light_count"]): item
for item in summary["forward_plus_gate"]["candidates"]}
self.assertEqual(set(hits), {("off", "direct", 32),
("on", "gpu-frustum", 64)})
self.assertAlmostEqual(hits[("off", "direct", 32)]["overhead_ms"], 1.0)
self.assertAlmostEqual(hits[("on", "gpu-frustum", 64)]["overhead_ms"], .6)
self.assertEqual(hits[("off", "direct", 32)]["zero_light_gpu_ms"], 10)
self.assertEqual(hits[("on", "gpu-frustum", 64)]["zero_light_gpu_ms"], 4)
def test_sweep_runs_fake_executable_and_preserves_all_raw_frames(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "fake_benchmark.py"
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
output = root / "café 世界"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertEqual(process.returncode, 0, process.stderr)
raw = list((output / "raw").glob("*.csv"))
self.assertEqual(len(raw), 54)
with (output / "merged.csv").open(newline="", encoding="utf-8") as stream:
merged = list(csv.DictReader(stream))
self.assertEqual(len(merged), 54 * 30)
self.assertEqual({row["source_csv"] for row in merged},
{path.name for path in raw})
report = json.loads((output / "summary.json").read_text(encoding="utf-8"))
self.assertEqual(report["runs_completed"], 54)
self.assertEqual(report["rows"], 54 * 30)
self.assertTrue(report["forward_plus_gate"]["triggered"])
one = json.loads(next((output / "raw").glob("*.args.json")).read_text())
self.assertEqual((one["width"], one["height"], one["warmup"], one["frames"]),
(1920, 1080, 10, 30))
self.assertEqual(one["validation"], "off")
def test_sweep_rejects_missing_gpu_raster_column(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "bad_benchmark.py"
fake.write_text(FAKE_BENCHMARK.replace(
'"gpu_main_raster_ms", "gpu_ms"', '"gpu_ms"').replace(
'gpu_main_raster_ms=.4 + .02 * a.lights, ', ''), encoding="utf-8")
output = root / "invalid"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("gpu_main_raster_ms", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_rejects_visibility_fallback_as_a_mode_measurement(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "fallback.py"
fake.write_text(FAKE_BENCHMARK.replace(
'effective_visibility=a.visibility', 'effective_visibility="direct"'),
encoding="utf-8")
output = root / "fallback-output"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("effective_visibility", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_rejects_a_scene_that_does_not_submit_requested_lights(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "wrong-count.py"
fake.write_text(FAKE_BENCHMARK.replace(
'submitted_local_lights=a.lights', 'submitted_local_lights=0'),
encoding="utf-8")
output = root / "wrong-count-output"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("submitted_local_lights", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_requires_known_driver_identity(self):
from benchmark_p3_lighting import sweep
with tempfile.TemporaryDirectory() as temporary:
fake = Path(temporary) / "fake.py"
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
with self.assertRaisesRegex(ValueError, "--driver"):
sweep(fake, Path(temporary) / "out", "off", "abc123")
def real_executable_smoke(executable: Path) -> None:
from benchmark_p3_lighting import REQUIRED_COLUMNS
choices = subprocess.run([executable, "--list-runs"], capture_output=True,
text=True, check=True)
declared = json.loads(choices.stdout)
if declared["lights"] != [0, 4, 16, 32, 64, 128] or len(declared["visibility"]) != 3:
raise AssertionError("C++ executable and Python sweep matrix disagree")
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "café 世界" / "smoke.csv"
subprocess.run([executable, "--lights", "4", "--shadows", "off",
"--visibility", "direct", "--width", "64", "--height", "64",
"--warmup", "0", "--frames", "1", "--validation", "on",
"--commit", "smoke", "--driver", "smoke-driver",
"--csv", output], capture_output=True, text=True, check=True)
with output.open(newline="", encoding="utf-8") as stream:
reader = csv.DictReader(stream)
columns, rows = reader.fieldnames or [], list(reader)
if set(REQUIRED_COLUMNS) - set(columns) or len(rows) != 1:
raise AssertionError("Real benchmark CSV lacks a complete single-frame row")
row = rows[0]
if (row["effective_visibility"] != "direct" or row["lighting_path"] != "forward" or
row["submitted_local_lights"] != "4" or row["validation_errors"] != "0" or
float(row["gpu_main_raster_ms"]) <= 0):
raise AssertionError("Real benchmark did not report the measured lighting path")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--real-executable":
real_executable_smoke(Path(sys.argv[2]).resolve())
else:
unittest.main()
+29
View File
@@ -36,6 +36,35 @@ def parameter(name: str, index: int, shape: str, access: str, stride: int | None
class ReflectionTests(unittest.TestCase):
def test_graphics_lighting_abi(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-lighting-abi-") as directory:
for source, entry, defines in (
("baseline.slang", "fragmentMain", []),
("gpu_scene.slang", "gpuVertexMain", ["--define", "FASET_GPU_GRAPHICS=1"]),
):
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / source), "--entry", entry,
*defines, "--output", directory],
capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
fragment = json.loads((Path(directory) / "fragmentMain.reflection.json").read_text())
gpu_vertex = json.loads((Path(directory) / "gpuVertexMain.reflection.json").read_text())
lighting = {
(d["set"], d["binding"]): (d["type"], d.get("element_stride"))
for d in fragment["layout"]["descriptors"]
}
self.assertEqual([lighting[1, i] for i in range(4)],
[("storage_buffer", 80), ("storage_buffer", 80),
("storage_buffer", 112), ("sampled_image_2d", None)])
graphics = {
(d["set"], d["binding"]): d["element_stride"]
for d in gpu_vertex["layout"]["descriptors"]
}
self.assertEqual([graphics[2, i] for i in range(3)], [224, 4, 208])
def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self):
# SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters.
# Our indirect commands always use firstInstance=0, so the Vulkan instance
-105
View File
@@ -1,105 +0,0 @@
"""Small contract checks for the cross-platform export verifier's Lua mode."""
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from verify_playable_exports import (device_class, project_matrix, project_workspace,
sha256, verify_capture, verify_lua_export,
verify_lua_execution, verify_package,
vulkan_device_details)
class ExportProjectMatrixTests(unittest.TestCase):
def test_native_project_workspace_is_outside_engine(self):
with tempfile.TemporaryDirectory() as directory:
engine = Path(directory) / "engine"
engine.mkdir()
internal_output = engine / ".cache" / "exports"
workspace = project_workspace(engine, internal_output)
try:
self.assertFalse(workspace.is_relative_to(engine))
finally:
workspace.rmdir()
external_output = Path(directory) / "evidence"
self.assertEqual(project_workspace(engine, external_output), external_output)
def test_lua_is_explicit_and_can_run_alone(self):
self.assertEqual([name for name, _, _ in project_matrix(False, False)],
["collect-2d", "collect-3d"])
self.assertEqual([name for name, _, _ in project_matrix(True, False)],
["collect-2d", "collect-3d", "lua"])
self.assertEqual(project_matrix(False, True), [("lua", 2, "lua")])
def test_lua_package_requires_source_and_license_without_cpp_stub(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Scripts").mkdir()
(root / "Notices/lua").mkdir(parents=True)
(root / "Scripts/player.lua").write_text("return {}\n", encoding="utf-8")
(root / "Notices/lua/LICENSE.txt").write_text("License\n", encoding="utf-8")
manifest = {"lua_enabled": True, "files": [
{"path": "Scripts/player.lua"}, {"path": "Notices/lua/LICENSE.txt"}]}
verify_lua_export(root, manifest)
with self.assertRaisesRegex(RuntimeError, "license"):
verify_lua_export(root, {**manifest, "files": manifest["files"][:1]})
(root / "Scripts/GameplayExtra.cpp").write_text("// stray\n", encoding="utf-8")
manifest["files"].append({"path": "Scripts/GameplayExtra.cpp"})
with self.assertRaisesRegex(RuntimeError, "C\\+\\+"):
verify_lua_export(root, manifest)
(root / "Scripts/GameplayExtra.cpp").unlink()
manifest["files"].pop()
(root / "Notices/lua/LICENSE.txt").unlink()
with self.assertRaisesRegex(RuntimeError, "license"):
verify_lua_export(root, manifest)
def test_lua_capture_requires_its_small_scene_palette(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "capture.ppm"
header = b"P6\n4 1\n255\n"
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5ffb233"))
self.assertEqual(verify_capture(path, language="lua")["sampled_colors"], 4)
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
verify_capture(path)
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5334c66"))
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
verify_capture(path, language="lua")
def test_driver_probe_selects_the_rendering_device(self):
summary = ("GPU0:\n deviceName = llvmpipe\n driverName = Mesa\n"
"GPU1:\n deviceName = Discrete GPU\n driverVersion = 123\n"
" driverName = Vendor\n driverInfo = 1.2.3\n")
self.assertEqual(vulkan_device_details(summary, "Discrete GPU"),
{"deviceName": "Discrete GPU", "driverVersion": "123",
"driverName": "Vendor", "driverInfo": "1.2.3"})
self.assertEqual(vulkan_device_details(summary, "Unknown"), {})
self.assertEqual(device_class("SwiftShader Device", {}), "software")
self.assertEqual(device_class("NVIDIA GeForce RTX 2080 Ti", {}), "physical")
self.assertEqual(device_class("Mystery Device", {}), "unknown")
def test_package_rejects_undeclared_file(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Scripts").mkdir()
(root / "Scripts/player.lua").write_text("return {}\n", encoding="utf-8")
file = root / "Scripts/player.lua"
manifest = {"format": "faset.export", "version": 1,
"configuration": "Release", "files": [
{"path": "Scripts/player.lua", "size": file.stat().st_size,
"sha256": sha256(file)}]}
import json
(root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
verify_package(root)
(root / "Scripts/GameplayExtra.cpp").write_text("// stray\n", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "undeclared"):
verify_package(root)
def test_lua_execution_requires_on_start_marker(self):
verify_lua_execution("Lua player ready: A/D move, Space jump, E reset\n")
with self.assertRaisesRegex(RuntimeError, "on_start"):
verify_lua_execution("")
if __name__ == "__main__":
unittest.main()
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Run the fixed P3 lighting sweep and retain raw per-frame GPU measurements.
The Forward+ threshold in the summary is a measurement result, not an automatic
renderer switch. Apply it to the Linux physical reference GPU; keep other devices
as separate functional/performance observations.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
import statistics
import subprocess
import sys
LIGHT_COUNTS = (0, 4, 16, 32, 64, 128)
VISIBILITY_MODES = ("direct", "gpu-frustum", "gpu-occlusion")
REPEATS = (1, 2, 3)
WARMUP_FRAMES = 10
MEASURED_FRAMES = 30
WIDTH, HEIGHT = 1920, 1080
REQUIRED_COLUMNS = (
"light_count", "shadows", "visibility", "frame", "device", "driver",
"commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
"readback_cpu_ms", "validation_errors", "run_index", "effective_visibility",
"lighting_path", "submitted_local_lights", "omitted_local_lights",
"shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height",
)
TIMING_COLUMNS = ("gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
"readback_cpu_ms")
def build_runs(shadows: str = "both") -> list[dict]:
if shadows not in ("off", "on", "both"):
raise ValueError(f"Unsupported shadow setting: {shadows}")
settings = ("off", "on") if shadows == "both" else (shadows,)
return [{"shadows": shadow, "visibility": mode, "light_count": lights,
"repeat": repeat}
for shadow in settings for mode in VISIBILITY_MODES
for lights in LIGHT_COUNTS for repeat in REPEATS]
def median(values: list[float]) -> float:
if not values:
raise ValueError("Cannot summarize empty measurements")
return float(statistics.median(values))
def p95(values: list[float]) -> float:
if not values:
raise ValueError("Cannot summarize empty measurements")
ordered = sorted(values)
return ordered[math.ceil(.95 * len(ordered)) - 1]
def _measurement(row: dict, name: str) -> float:
try:
value = float(row[name])
except (KeyError, TypeError, ValueError) as error:
raise ValueError(f"Invalid {name} in benchmark CSV") from error
if not math.isfinite(value) or value < 0:
raise ValueError(f"Invalid {name} in benchmark CSV: {value}")
return value
def summarize_rows(rows: list[dict]) -> dict:
"""Use the median of each independent run's median, then compare like baselines."""
grouped: dict[tuple[str, str, int], dict[int, list[dict]]] = {}
for row in rows:
try:
key = (row["shadows"], row["visibility"], int(row["light_count"]))
repeat = int(row["run_index"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Benchmark row lacks shadow/mode/light/repeat identity") from error
if key[0] not in ("off", "on") or key[1] not in VISIBILITY_MODES or repeat < 1:
raise ValueError(f"Invalid benchmark configuration: {key}, repeat {repeat}")
for name in TIMING_COLUMNS:
_measurement(row, name)
grouped.setdefault(key, {}).setdefault(repeat, []).append(row)
configurations = []
lookup = {}
for (shadows, visibility, lights), repeats in sorted(grouped.items()):
run_summaries = []
for repeat, samples in sorted(repeats.items()):
run_summaries.append({
"run_index": repeat, "frames": len(samples),
"median_ms": {name: median([_measurement(row, name) for row in samples])
for name in TIMING_COLUMNS},
})
entry = {
"shadows": shadows, "visibility": visibility, "light_count": lights,
"runs": run_summaries,
"median_ms": {name: median([run["median_ms"][name] for run in run_summaries])
for name in TIMING_COLUMNS},
"p95_ms": {name: p95([_measurement(row, name) for samples in repeats.values()
for row in samples]) for name in TIMING_COLUMNS},
}
configurations.append(entry)
lookup[(shadows, visibility, lights)] = entry
candidates = []
evaluated = []
for entry in configurations:
if entry["light_count"] not in (32, 64, 128):
continue
baseline = lookup.get((entry["shadows"], entry["visibility"], 0))
if baseline is None:
raise ValueError("Forward+ gate requires a zero-light baseline for each mode/shadow setting")
zero_gpu = baseline["median_ms"]["gpu_ms"]
if zero_gpu <= 0:
raise ValueError("Forward+ gate requires positive zero-light GPU frame timing")
overhead = (entry["median_ms"]["gpu_main_raster_ms"] -
baseline["median_ms"]["gpu_main_raster_ms"])
result = {"shadows": entry["shadows"], "visibility": entry["visibility"],
"light_count": entry["light_count"], "overhead_ms": overhead,
"zero_light_gpu_ms": zero_gpu,
"overhead_percent_of_zero_gpu": 100 * overhead / zero_gpu,
"absolute_threshold_reached": overhead >= 1.0,
"relative_threshold_reached": overhead >= .15 * zero_gpu}
evaluated.append(result)
if result["absolute_threshold_reached"] or result["relative_threshold_reached"]:
candidates.append(result)
return {"configurations": configurations,
"forward_plus_gate": {"triggered": bool(candidates), "candidates": candidates,
"evaluated": evaluated,
"basis": "median of three independent run medians; same-mode/shadow zero-light GPU baseline"}}
def _read_run_csv(path: Path, run: dict, commit: str) -> tuple[list[str], list[dict]]:
with path.open(newline="", encoding="utf-8") as stream:
reader = csv.DictReader(stream)
columns = reader.fieldnames or []
missing = sorted(set(REQUIRED_COLUMNS) - set(columns))
if missing:
raise ValueError(f"{path}: missing CSV column(s): {', '.join(missing)}")
rows = list(reader)
if len(rows) != MEASURED_FRAMES:
raise ValueError(f"{path}: expected {MEASURED_FRAMES} measured frames, got {len(rows)}")
frames = set()
for row in rows:
expected = {"light_count": str(run["light_count"]), "shadows": run["shadows"],
"visibility": run["visibility"], "run_index": str(run["repeat"]),
"commit": commit, "width": str(WIDTH), "height": str(HEIGHT)}
for name, value in expected.items():
if row[name] != value:
raise ValueError(f"{path}: {name} mismatch: expected {value}, got {row[name]}")
if row["effective_visibility"] != run["visibility"]:
raise ValueError(f"{path}: effective_visibility fell back from {run['visibility']}")
if row["submitted_local_lights"] != str(run["light_count"]) or row["omitted_local_lights"] != "0":
raise ValueError(f"{path}: submitted_local_lights or omitted_local_lights disagrees with the workload")
try:
frame = int(row["frame"])
errors = int(row["validation_errors"])
except ValueError as error:
raise ValueError(f"{path}: invalid frame or validation error count") from error
if frame in frames or errors != 0:
raise ValueError(f"{path}: duplicate frame or Vulkan validation error")
frames.add(frame)
if not row["device"] or not row["driver"] or not row["lighting_path"]:
raise ValueError(f"{path}: device, driver and effective lighting path are required")
for name in TIMING_COLUMNS:
_measurement(row, name)
return columns, rows
def _git_revision() -> str:
root = Path(__file__).resolve().parents[1]
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"],
text=True).strip()
def sweep(executable: Path, output: Path, shadows: str, commit: str,
validation: str = "off", driver: str | None = None) -> dict:
if not executable.is_file():
raise ValueError(f"Benchmark executable does not exist: {executable}")
if driver is None or not driver.strip() or driver.strip().lower() == "unknown":
raise ValueError("A measured sweep requires an explicit --driver identity")
if output.exists() and any(output.iterdir()):
raise ValueError(f"Output directory must be new or empty: {output}")
raw = output / "raw"
raw.mkdir(parents=True)
all_rows = []
columns = None
runs = build_runs(shadows)
command_prefix = [sys.executable, str(executable)] if executable.suffix.lower() == ".py" else [str(executable)]
for run in runs:
filename = (f"shadows-{run['shadows']}_{run['visibility']}_"
f"lights-{run['light_count']:03d}_run-{run['repeat']}.csv")
target = raw / filename
command = command_prefix + [
"--lights", str(run["light_count"]), "--shadows", run["shadows"],
"--visibility", run["visibility"], "--csv", str(target),
"--run-index", str(run["repeat"]), "--commit", commit,
"--validation", validation, "--width", str(WIDTH), "--height", str(HEIGHT),
"--warmup", str(WARMUP_FRAMES), "--frames", str(MEASURED_FRAMES),
]
if driver is not None:
command += ["--driver", driver]
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=180)
if result.returncode != 0:
raise RuntimeError(f"Benchmark failed for {filename}: {result.stderr[-2000:]}")
run_columns, samples = _read_run_csv(target, run, commit)
if columns is None:
columns = run_columns
elif columns != run_columns:
raise ValueError(f"{target}: CSV schema differs from other runs")
all_rows.extend({**row, "source_csv": filename} for row in samples)
merged = output / "merged.csv"
with merged.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=[*(columns or []), "source_csv"])
writer.writeheader()
writer.writerows(all_rows)
summary = {"format": "faset.p3-lighting-benchmark", "version": 1,
"commit": commit, "warmup_frames_per_run": WARMUP_FRAMES,
"measured_frames_per_run": MEASURED_FRAMES, "width": WIDTH, "height": HEIGHT,
"validation": validation, "driver": driver,
"runs_completed": len(runs), "rows": len(all_rows),
**summarize_rows(all_rows)}
(output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n",
encoding="utf-8")
return summary
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--list-runs", action="store_true", help="Print the deterministic sweep matrix as JSON")
mode.add_argument("--sweep", action="store_true", help="Run every configuration and retain raw CSV")
parser.add_argument("--shadows", choices=("off", "on", "both"), default="both")
parser.add_argument("--executable", type=Path, help="Built C++ benchmark executable")
parser.add_argument("--output", type=Path, help="New or empty evidence directory")
parser.add_argument("--commit", help="Source revision; defaults to this checkout's HEAD")
parser.add_argument("--driver", help="Required driver identity for a measured sweep")
parser.add_argument("--validation", choices=("on", "off"), default="off")
args = parser.parse_args()
if args.list_runs:
print(json.dumps({"format": "faset.p3-lighting-run-matrix", "version": 1,
"runs": build_runs(args.shadows)}, indent=2))
return 0
if args.executable is None or args.output is None:
parser.error("--sweep requires --executable and --output")
try:
summary = sweep(args.executable.resolve(), args.output.resolve(), args.shadows,
args.commit or _git_revision(), args.validation, args.driver)
except (OSError, ValueError, RuntimeError) as error:
print(f"P3 lighting benchmark failed: {error}", file=sys.stderr)
return 1
print(json.dumps({"summary": str(args.output.resolve() / "summary.json"),
"runs_completed": summary["runs_completed"],
"forward_plus_threshold_reached": summary["forward_plus_gate"]["triggered"]}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32 -423
View File
@@ -5,173 +5,32 @@ Numbers are observations, not CI pass/fail thresholds. Linux peak RSS comes from
GNU time for the command and its waited-for children; other platforms report null.
"""
import argparse
import copy
import datetime
import hashlib
import json
import math
import os
from pathlib import Path
import platform
import queue
import random
import shutil
import statistics
import subprocess
import threading
import time
def summarize(values):
"""Median and nearest-rank p95 of positive, finite observations."""
if not values or any(not isinstance(value, (int, float)) or
not math.isfinite(value) or value <= 0 for value in values):
raise ValueError("A distribution requires positive finite samples")
ordered = sorted(values)
return {"median": statistics.median(ordered),
"p95": ordered[math.ceil(.95 * len(ordered)) - 1]}
def parse_peak_rss(text):
# GNU time prefixes its -o output with a status sentence when the measured
# command exits nonzero. The JSON format is the final nonempty line.
for line in reversed(text.splitlines()):
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(value, dict) and isinstance(value.get("peak_rss_kib"), int):
return value["peak_rss_kib"]
raise ValueError("GNU time did not produce a peak RSS record")
def validate_report(report):
if report.get("format") != "faset.workflow-measurements" or report.get("version") != 2:
raise ValueError("Expected a version 2 Faset workflow report")
if not isinstance(report.get("revision"), str) or not report["revision"].strip():
raise ValueError("A source revision is required")
hashes = report.get("source_hashes")
if not isinstance(hashes, dict) or not hashes or any(
not isinstance(value, str) or len(value) != 64 or
any(character not in "0123456789abcdef" for character in value)
for value in hashes.values()):
raise ValueError("Exact input SHA-256 hashes are required")
configuration = report.get("build_configuration")
if configuration not in {"Debug", "Release", "RelWithDebInfo"}:
raise ValueError("Build configuration is missing or unknown")
samples = report.get("samples")
if not isinstance(samples, dict) or not samples:
raise ValueError("Raw labelled samples are required")
for label, rows in samples.items():
if not isinstance(rows, list) or not rows:
raise ValueError(f"{label} has no raw samples")
if (label.startswith("warm_") or label.startswith("changed_")) and len(rows) < 5:
raise ValueError(f"{label} requires at least five measured repetitions")
for row in rows:
if row.get("build_configuration") != configuration:
raise ValueError(f"{label} mixes build configurations")
if row.get("source_hash") not in hashes.values():
raise ValueError(f"{label} lacks a declared input source hash")
summarize([row.get("seconds")])
if label == "windowed_first_presented_frame" and \
row.get("presentation_mode") != "windowed":
raise ValueError("An offscreen frame cannot be labelled windowed presentation")
if label == "offscreen_first_rendered_frame" and \
row.get("presentation_mode") != "offscreen":
raise ValueError("A windowed frame cannot be labelled offscreen")
def generate_scene_fixture(scene, *, seed, count):
"""Add deterministic visible objects without mutating the checked-in scene."""
if not isinstance(seed, int) or count < 0 or not scene.get("entities"):
raise ValueError("A seed, nonnegative count, and source entity are required")
generated = copy.deepcopy(scene)
rng = random.Random(seed)
reference = next((entry for entry in scene["entities"] if any(
component.get("type") in {"faset.mesh", "faset.sprite"}
for component in entry.get("components", []))), scene["entities"][0])
for index in range(count):
entity = copy.deepcopy(reference)
entity["id"] = f"measured-{seed}-{index:05d}"
entity["name"] = f"Measured object {index + 1}"
entity["parent"] = None
entity["components"] = [component for component in entity.get("components", [])
if component.get("type") in {"faset.transform", "faset.mesh",
"faset.sprite"}]
for component in entity["components"]:
component["id"] = f"{entity['id']}-{component['type']}"
fields = component.get("fields", {})
if component["type"] == "faset.transform":
position = list(fields.get("position", [0, 0, 0]))
position[0] = ((index % 32) - 16) * .45 + rng.uniform(-.05, .05)
position[2 if scene.get("dimension", 3) == 3 else 1] = \
(index // 32) * .45 + rng.uniform(-.05, .05)
fields["position"] = position
fields["scale"] = [.3, .3, .3]
elif component["type"] == "faset.sprite":
fields["size"] = [.3, .3]
generated["entities"].append(entity)
return generated
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--editor", type=Path, required=True)
parser.add_argument("--project", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--lua-project", type=Path,
default=Path(__file__).resolve().parents[1] / "examples/lua")
parser.add_argument("--ui-latency-binary", type=Path)
args = parser.parse_args()
editor, source, output = args.editor.resolve(), args.project.resolve(), args.output.resolve()
lua_source = args.lua_project.resolve()
ui_binary = (args.ui_latency_binary or
editor.parent / ("faset_editor_ui_latency.exe" if os.name == "nt" else
"faset_editor_ui_latency")).resolve()
for file in (editor, ui_binary, source / "project.faset.json",
lua_source / "project.faset.json"):
if not file.is_file():
raise RuntimeError(f"Required measurement input is missing: {file}")
if output.exists():
raise RuntimeError("Use a new output directory; existing evidence is never overwritten")
output.mkdir(parents=True)
project = output / "project"
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
lua_project = output / "lua-project"
shutil.copytree(lua_source, lua_project,
ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
raw = output / "raw"
raw.mkdir()
samples = {}
source_hashes = {}
counters = {}
samples = []
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def remember(path, variant, base=project):
key = f"{base.name}/{path.relative_to(base).as_posix()}#{variant}"
value = digest(path)
source_hashes[key] = value
return value
gameplay = project / "Scripts/Gameplay.cpp"
header = project / "Scripts/Gameplay.hpp"
if not gameplay.is_file() or not header.is_file():
raise RuntimeError("The C++ project needs Scripts/Gameplay.cpp and Gameplay.hpp")
baseline_hash = remember(gameplay, "baseline")
project_hashes = {file.relative_to(project).as_posix(): digest(file)
for file in sorted(project.rglob("*")) if file.is_file()}
lua_input = json.loads((lua_project / "project.faset.json").read_text(encoding="utf-8"))
lua_file = lua_project / lua_input["scripting"]["lua"]["scripts"][0]
lua_baseline_hash = remember(lua_file, "baseline", lua_project)
def run(label, arguments, *, source_hash=baseline_hash, timeout=1800,
expected_failure=False, record=True):
index = counters.get(label, 0) + 1
counters[label] = index
stem = f"{label}-{index:02d}"
memory = raw / (stem + "-memory.json")
def run(label, arguments, timeout=1800):
memory = output / (label + "-memory.json")
command = [str(arg) for arg in arguments]
wrapped = command
if platform.system() == "Linux" and Path("/usr/bin/time").is_file():
@@ -180,294 +39,44 @@ def main():
result = subprocess.run(wrapped, cwd=output, capture_output=True, text=True,
encoding="utf-8", timeout=timeout)
elapsed = time.perf_counter() - started
stdout_file, stderr_file = raw / (stem + "-stdout.txt"), raw / (stem + "-stderr.txt")
stdout_file.write_text(result.stdout, encoding="utf-8")
stderr_file.write_text(result.stderr, encoding="utf-8")
sample = {"seconds": elapsed, "returncode": result.returncode,
"build_configuration": "Debug", "source_hash": source_hash,
"command": command, "stdout": str(stdout_file.relative_to(output)),
"stderr": str(stderr_file.relative_to(output)),
"peak_rss_kib": parse_peak_rss(memory.read_text())
if memory.exists() else None}
(raw / (stem + ".json")).write_text(json.dumps(sample, indent=2) + "\n",
encoding="utf-8")
if record:
samples.setdefault(label, []).append(sample)
(output / "progress.json").write_text(json.dumps(samples, indent=2) + "\n",
encoding="utf-8")
if expected_failure and result.returncode == 0:
raise RuntimeError(f"{label} unexpectedly succeeded")
if result.returncode and not expected_failure:
(output / (label + "-stdout.txt")).write_text(result.stdout, encoding="utf-8")
(output / (label + "-stderr.txt")).write_text(result.stderr, encoding="utf-8")
sample = {"name": label, "seconds": elapsed, "returncode": result.returncode,
"peak_rss_kib": json.loads(memory.read_text())["peak_rss_kib"] if memory.exists() else None}
samples.append(sample)
(output / "progress.json").write_text(json.dumps(samples, indent=2), encoding="utf-8")
if result.returncode:
raise RuntimeError(f"{label} failed: {result.stderr[-4000:]} {result.stdout[-2000:]}")
print(f"{stem}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True)
return result, sample
print(f"{label}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True)
return result
def call(label, name, arguments=None, *, root=project, source_hash=baseline_hash,
record=True):
result, sample = run(label, [editor, "--project", root, "--command",
json.dumps({"name": name, "arguments": arguments or {}}),
"--wait"], source_hash=source_hash, record=record)
def call(label, name, arguments=None):
result = run(label, [editor, "--project", project, "--command",
json.dumps({"name": name, "arguments": arguments or {}}), "--wait"])
document = json.loads(result.stdout)
if "state" in document and document["state"] != "succeeded":
raise RuntimeError(f"{label} job did not succeed: {document}")
if name == "faset_build":
sample["schema_cache_hit"] = document.get("result", {}).get("schema_cache_hit")
sample["generation_reused"] = document.get("result", {}).get("generation_reused")
return document, sample
return document
for index in range(3):
call("headless_editor_command_startup", "faset_project")
# This is window creation plus two frames and shutdown, not first-present latency.
run("windowed_editor_two_frames", [editor, "--project", project, "--frames", "2"])
call(f"headless-startup-{index + 1}", "faset_project")
# Separate GUI startup from headless command latency. Includes two frames and
# shutdown, not a claimed first-visible-frame timestamp.
run("editor-gui-two-frames", [editor, "--project", project, "--frames", "2"])
manifest = project / "Assets/exit-arch/manifest.json"
if manifest.exists():
call("cold_asset_import", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("cached_asset_import", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
cold, _ = call("cold_configure_build", "faset_build")
call("warmup_unchanged_build", "faset_build", record=False)
for _ in range(5):
reused, _ = call("warm_unchanged_build", "faset_build")
if not reused["result"].get("schema_cache_hit"):
raise RuntimeError("An unchanged build did not hit the verified schema cache")
original_cpp = gameplay.read_bytes()
original_header = header.read_bytes()
for index in range(6):
gameplay.write_bytes(original_cpp +
f"\n// P1 C++ iteration {index}\n".encode("utf-8"))
source_hash = remember(gameplay, f"cpp-edit-{index}")
stale, _ = call("schema_stale_check", "faset_schema_status",
source_hash=source_hash, record=False)
if not stale["stale"]:
raise RuntimeError("A C++ source edit did not mark gameplay metadata stale")
label = "warmup_changed_cpp_build" if index == 0 else "changed_cpp_build"
call(label, "faset_build", source_hash=source_hash, record=index != 0)
for index in range(6):
header.write_bytes(original_header +
f"\n// P1 header iteration {index}\n".encode("utf-8"))
source_hash = remember(header, f"header-edit-{index}")
label = "warmup_changed_header_build" if index == 0 else "changed_header_build"
call(label, "faset_build", source_hash=source_hash, record=index != 0)
working_header = header.read_bytes()
header.write_bytes(working_header + b"\n#error FASET_P1_EXPECTED_BUILD_FAILURE\n")
failed_hash = remember(header, "intentional-failure")
run("build_failure", [editor, "--project", project, "--command",
json.dumps({"name": "faset_build", "arguments": {}}), "--wait"],
source_hash=failed_hash, expected_failure=True)
header.write_bytes(working_header)
recovery_hash = remember(header, "recovery")
built, _ = call("build_recovery", "faset_build", source_hash=recovery_hash)
settings = json.loads((project / "project.faset.json").read_text(encoding="utf-8"))
scene = project / settings["start_scene"]
player = Path(built["result"]["player"])
player_profiles = []
for index in range(5):
profile = raw / f"cpp-first-frame-{index + 1:02d}.json"
run("player_one_frame_process", [player, "--scene", scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"1", "--profile", profile],
source_hash=recovery_hash)
profile_data = json.loads(profile.read_text(encoding="utf-8"))
if profile_data["presentation_mode"] != "offscreen" or \
profile_data["completed_frames"] != 1:
raise RuntimeError("The first-frame profile is not one rendered offscreen frame")
player_profiles.append(profile_data)
samples.setdefault("offscreen_first_rendered_frame", []).append({
"seconds": profile_data["startup_ms"]["main_to_first_frame"] / 1000,
"build_configuration": "Debug", "source_hash": recovery_hash,
"presentation_mode": profile_data["presentation_mode"],
"profile": str(profile.relative_to(output))})
# The UI probe measures synthetic keyboard input through snapshot rendering,
# with no display presentation claim.
ui_profile = raw / "editor-input-latency.json"
run("editor_ui_probe_process", [ui_binary, ui_profile], source_hash=recovery_hash)
ui_data = json.loads(ui_profile.read_text(encoding="utf-8"))
if ui_data["presentation_mode"] != "offscreen" or \
ui_data["measured_frames"] != len(ui_data["samples"]):
raise RuntimeError("UI probe lost raw frames or mislabelled presentation")
samples["editor_input_visible_frame"] = [
{"seconds": row["milliseconds"] / 1000, "build_configuration": "Debug",
"source_hash": recovery_hash, "presentation_mode": "offscreen",
"input": row["gizmo"], "profile": str(ui_profile.relative_to(output))}
for row in ui_data["samples"]]
original_lua = lua_file.read_bytes()
lua_build, _ = call("lua_initial_build", "faset_build", root=lua_project,
source_hash=lua_baseline_hash)
lua_player = Path(lua_build["result"]["player"])
lua_scene = lua_project / lua_input["start_scene"]
control = lua_project / ".faset/measurement-control.json"
process = subprocess.Popen([str(lua_player), "--scene", str(lua_scene), "--project",
str(lua_project), "--control", str(control), "--watch-lua",
"--headless", "--frames", "10000000"], cwd=output,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8")
events = queue.Queue()
stderr_lines = []
def collect_lua():
for line in process.stderr:
event = (time.perf_counter(), line)
stderr_lines.append(event)
events.put(event)
reader = threading.Thread(target=collect_lua, daemon=True)
reader.start()
def wait_lua(marker, timeout=20):
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
try:
event = events.get(timeout=min(.2, max(.001, deadline - time.perf_counter())))
except queue.Empty:
if process.poll() is not None:
break
continue
if marker in event[1]:
return event[0]
raise RuntimeError(f"Lua Player did not report {marker}; see raw/lua-reload-stderr.txt")
try:
# Startup settles before the watch/reload timing begins.
wait_lua("Lua", timeout=20)
for index in range(6):
lua_file.write_bytes(original_lua +
f"\n-- P1 reload iteration {index}\n".encode("utf-8"))
started = time.perf_counter()
source_hash = remember(lua_file, f"reload-{index}", lua_project)
finished = wait_lua("Lua reloaded:")
if index:
samples.setdefault("changed_lua_reload", []).append({
"seconds": finished - started, "build_configuration": "Debug",
"source_hash": source_hash, "presentation_mode": "offscreen",
"player_log": "raw/lua-reload-stderr.txt"})
control.parent.mkdir(parents=True, exist_ok=True)
staging = control.with_suffix(".tmp")
staging.write_text(json.dumps({"sequence": 1, "command": "stop"}),
encoding="utf-8")
staging.replace(control)
process.wait(timeout=20)
if process.returncode:
raise RuntimeError("Lua Player failed during reload measurements")
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
reader.join(timeout=2)
(raw / "lua-reload-stdout.txt").write_text(process.stdout.read(), encoding="utf-8")
(raw / "lua-reload-stderr.txt").write_text("".join(line for _, line in stderr_lines),
encoding="utf-8")
lua_file.write_bytes(original_lua)
lifecycle_profile = raw / "cpp-3000-frame-lifecycle.json"
run("resource_lifecycle_process", [player, "--scene", scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"3000", "--profile", lifecycle_profile],
source_hash=recovery_hash)
lifecycle = json.loads(lifecycle_profile.read_text(encoding="utf-8"))
frames = lifecycle["samples"]
if len(frames) != 3000:
raise RuntimeError("Resource lifecycle did not complete 3,000 frames")
memory_start = max(row["gpu_allocated_bytes"] for row in frames[100:200])
memory_end = max(row["gpu_allocated_bytes"] for row in frames[-100:])
resource_growth = {"reference_frames": [101, 200], "end_frames": [2901, 3000],
"explicit_gpu_bytes_reference_max": memory_start,
"explicit_gpu_bytes_end_max": memory_end,
"explicit_gpu_bytes_growth": memory_end - memory_start,
"texture_count_reference_max": max(row["texture_count"]
for row in frames[100:200]),
"texture_count_end_max": max(row["texture_count"]
for row in frames[-100:])}
generated_scene = generate_scene_fixture(json.loads(scene.read_text(encoding="utf-8")),
seed=20260924, count=512)
large_scene = raw / "generated-512.scene.json"
large_scene.write_text(json.dumps(generated_scene, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
large_hash = remember(large_scene, "generated-512", output)
large_profile = raw / "generated-512-profile.json"
run("larger_generated_scene_process", [player, "--scene", large_scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"240", "--profile", large_profile],
source_hash=large_hash)
large_data = json.loads(large_profile.read_text(encoding="utf-8"))
engine = Path(__file__).resolve().parents[1]
revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=engine,
capture_output=True, text=True, check=True).stdout.strip()
dirty = subprocess.run(["git", "status", "--porcelain"], cwd=engine,
capture_output=True, text=True, check=True).stdout.strip()
cpu = platform.processor()
if Path("/proc/cpuinfo").exists():
cpu = next((line.split(":", 1)[1].strip() for line in
Path("/proc/cpuinfo").read_text().splitlines()
if line.startswith("model name")), cpu)
ram_kib = None
if Path("/proc/meminfo").exists():
ram_kib = next((int(line.split()[1]) for line in
Path("/proc/meminfo").read_text().splitlines()
if line.startswith("MemTotal:")), None)
def version(arguments):
try:
return subprocess.run(arguments, capture_output=True, text=True, timeout=15,
encoding="utf-8").stdout.splitlines()[0]
except (OSError, subprocess.TimeoutExpired, IndexError):
return None
report = {
"format": "faset.workflow-measurements", "version": 2,
"recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"revision": revision, "working_tree_dirty": bool(dirty),
"source_hashes": source_hashes, "project_hashes_at_copy": project_hashes,
"build_configuration": "Debug", "source_project": str(source),
"lua_source_project": str(lua_source), "editor": str(editor),
"host": {"platform": platform.platform(), "cpu": cpu, "ram_kib": ram_kib,
"logical_cpus": os.cpu_count(), "python": platform.python_version()},
"toolchain": {"cmake": version(["cmake", "--version"]),
"cxx": version(["clang++", "--version"]),
"slang": version(["slangc", "-version"])},
"gpu": {"player_device": player_profiles[0]["device"],
"ui_device": ui_data["device"],
"driver": version(["nvidia-smi", "--query-gpu=driver_version",
"--format=csv,noheader"])},
"settings": {"resolution": [player_profiles[0]["width"],
player_profiles[0]["height"]],
"validation_enabled": player_profiles[0]["validation_enabled"],
"readback_enabled": True, "presentation_mode": "offscreen",
"source_scene_entities": len(json.loads(scene.read_text())["entities"]),
"generated_scene_entities": len(generated_scene["entities"])},
"samples": samples,
"summary_seconds": {label: summarize([row["seconds"] for row in rows])
for label, rows in samples.items()},
"resource_lifecycle": resource_growth,
"large_scene_summary_ms": large_data["summary_ms"],
"coverage": {"windowed_first_presented_frame":
"unmeasured; only offscreen rendered frames were sampled",
"reference_release_budgets":
"not compared with this Debug development workflow"},
"method": [
"Fresh disposable C++ and Lua project copies; checked-in sources never modified.",
"Cold build means empty project .faset caches, not cold OS cache or dependency download.",
"Warm/changed C++ and header builds have one warm-up then five measured repetitions.",
"Lua edit-to-reload uses a persistent watched Player, one warm-up and five edits; "
"wall time includes up to 500 ms watcher polling and stderr notification.",
"First frame is Player main-to-first-completed offscreen frame; OS loader excluded.",
"UI latency uses 10 warm-up and 100 keyboard-input to offscreen-render frames; "
"there is no window presentation timing claim.",
"The 3,000-frame lifecycle compares maximum explicit Vulkan allocation and texture "
"counts in frames 101-200 and 2901-3000; it is not a whole-process leak proof.",
"Times are observations under ambient host load, not CI pass/fail thresholds."]}
validate_report(report)
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Report: {output / 'report.json'}", flush=True)
call("import-empty-cache", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("import-existing-generation", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("initial-debug-build", "faset_build")
call("unchanged-debug-build", "faset_build")
gameplay = project / "Scripts/Gameplay.cpp"
original = gameplay.read_bytes()
gameplay.write_bytes(original + b"\n// Measurement: one gameplay translation unit changed.\n")
# Verify the user-facing stale status before a successful replacement.
assert call("schema-stale-after-edit", "faset_schema_status")["stale"]
iteration_started = time.perf_counter()
built = call("changed-debug-build", "faset_build")["result"]
profile = output / "development-player.json"
settings = json.loads((project / "project.faset.json").read_text(encoding="utf-8"))
run("changed-build-player-first-frame", [built["player"], "--scene",
project / settings["start_scene"], "--assets", project / ".faset/cache",
-22
View File
@@ -1,22 +0,0 @@
-- 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
+13 -136
View File
@@ -15,7 +15,6 @@ import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
@@ -42,38 +41,6 @@ def sha256(path: Path) -> str:
return hashlib.file_digest(stream, "sha256").hexdigest()
def vulkan_device_details(summary: str, device: str) -> dict:
"""Return the driver identity from vulkaninfo for the Player-selected device."""
for section in re.split(r"(?m)^GPU\d+:\s*$", summary)[1:]:
fields = dict(re.findall(
r"(?m)^\s*(apiVersion|driverVersion|driverID|driverName|driverInfo|deviceName)"
r"\s*=\s*(.+?)\s*$", section))
if fields.get("deviceName") == device:
return fields
return {}
def device_class(device: str, driver: dict) -> str:
identity = " ".join((device, driver.get("driverName", ""), driver.get("driverInfo", ""))).lower()
if any(name in identity for name in ("swiftshader", "llvmpipe", "lavapipe", "softpipe")):
return "software"
if any(name in identity for name in ("nvidia", "geforce", "radeon", "amd", "intel", "arc")):
return "physical"
return "unknown"
def git_identity(engine: Path) -> dict:
def git(*arguments: str) -> str:
return subprocess.run(["git", "-C", str(engine), *arguments], check=True,
capture_output=True, text=True).stdout
revision = git("rev-parse", "HEAD").strip()
status = git("status", "--porcelain", "--untracked-files=normal")
diff = git("diff", "--binary", "HEAD")
return {"revision": revision, "dirty": bool(status),
"status_lines": status.splitlines(),
"diff_sha256": hashlib.sha256(diff.encode("utf-8")).hexdigest() if diff else None}
def run(arguments: list[str | Path], cwd: Path, log: Path, timeout: int = 1800) -> dict:
command = [str(argument) for argument in arguments]
print(f"{log.stem}: {subprocess.list2cmdline(command)}", flush=True)
@@ -126,15 +93,10 @@ def verify_package(directory: Path) -> dict:
require(path.is_file() and not path.is_symlink(), f"Missing packaged file: {path}")
require(path.stat().st_size == entry["size"] and sha256(path) == entry["sha256"],
f"Packaged file checksum mismatch: {path}")
declared = [entry["path"] for entry in manifest["files"]]
actual = {path.relative_to(directory).as_posix() for path in directory.rglob("*")
if path.is_file() or path.is_symlink()}
require(len(declared) == len(set(declared)) and actual == set(declared) | {"manifest.json"},
"Package has undeclared, duplicate or missing files")
return manifest
def verify_capture(path: Path, *, language: str = "cpp") -> dict:
def verify_capture(path: Path) -> dict:
header, dimensions, maximum, pixels = path.read_bytes().split(b"\n", 3)
require(header == b"P6" and maximum == b"255", "Expected RGB PPM screenshot")
width, height = (int(value) for value in dimensions.split())
@@ -142,57 +104,10 @@ def verify_capture(path: Path, *, language: str = "cpp") -> dict:
"Incomplete screenshot")
step = 3 * max(1, width * height // 10000)
colors = len({pixels[index:index + 3] for index in range(0, len(pixels), step)})
# The checked-in Lua sample deliberately has four flat palette colors.
require(colors >= (4 if language == "lua" else 6),
"Screenshot lacks the expected game geometry/colors")
if language == "lua":
sampled = {pixels[index:index + 3] for index in range(0, len(pixels), step)}
require(any(r > 220 and g > 120 and b < 100 for r, g, b in sampled) and
any(r < 100 and g > 150 and b > 180 for r, g, b in sampled),
"Lua screenshot lacks its gold collectible or cyan player")
require(colors >= 6, "Screenshot lacks the expected game geometry/colors")
return {"width": width, "height": height, "sampled_colors": colors, "sha256": sha256(path)}
def project_matrix(include_lua: bool, only_lua: bool) -> list[tuple[str, int, str]]:
"""Keep the existing two-game default; explicitly opt in to the Lua export."""
if only_lua:
return [("lua", 2, "lua")]
projects = [("collect-2d", 2, "cpp"), ("collect-3d", 3, "cpp")]
if include_lua:
projects.append(("lua", 2, "lua"))
return projects
def project_workspace(engine: Path, output: Path) -> Path:
"""Native builds must never be staged under the engine source tree."""
if output.is_relative_to(engine):
return Path(tempfile.mkdtemp(prefix="faset-playable-projects-"))
return output
def verify_lua_export(directory: Path, manifest: dict) -> None:
require(manifest.get("lua_enabled") is True, "Lua export did not enable the Lua runtime")
scripts = [entry["path"] for entry in manifest["files"]
if entry["path"].startswith("Scripts/") and entry["path"].endswith(".lua")]
require(scripts and all((directory / path).is_file() for path in scripts),
"Lua export has no packaged gameplay source")
require("Notices/lua/LICENSE.txt" in {entry["path"] for entry in manifest["files"]} and
(directory / "Notices/lua/LICENSE.txt").is_file(),
"Lua export is missing its license notice")
require(not any(path.startswith("Scripts/") and
path.lower().endswith((".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h"))
for path in (entry["path"] for entry in manifest["files"])),
"Lua-only export unexpectedly contains C++ gameplay source")
require(not any(path.endswith(".luarc.json") for path in
(entry["path"] for entry in manifest["files"])),
"Lua-only export includes Editor language-server configuration")
def verify_lua_execution(stderr: str) -> None:
require("Lua player ready: A/D move, Space jump, E reset" in stderr,
"Lua gameplay on_start did not execute in the relocated Player")
def main() -> int:
# Keep captured CI logs portable even when the Windows console uses a legacy code page.
sys.stdout.reconfigure(encoding="utf-8")
@@ -202,10 +117,6 @@ def main() -> int:
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--standalone-root", type=Path,
help="New/empty directory outside the engine and output trees")
parser.add_argument("--include-lua", action="store_true",
help="Also export, relocate, validate and render examples/lua")
parser.add_argument("--only-lua", action="store_true",
help="Run only the Lua project (useful for focused local verification)")
args = parser.parse_args()
engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve()
require(editor.is_file(), f"Editor does not exist: {editor}")
@@ -218,50 +129,26 @@ def main() -> int:
require(not standalone.exists() or not any(standalone.iterdir()),
"Standalone root must be new/empty")
standalone.mkdir(parents=True, exist_ok=True)
workspace = project_workspace(engine, output)
evidence, projects = output / "evidence", workspace / "Faset Café 世界"
evidence, projects = output / "evidence", output / "Faset Café 世界"
evidence.mkdir()
projects.mkdir()
source_identity = git_identity(engine)
vulkan_summary = ""
if shutil.which("vulkaninfo"):
try:
probe = subprocess.run(["vulkaninfo", "--summary"], capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=30)
if probe.returncode == 0:
vulkan_summary = probe.stdout
(evidence / "vulkaninfo-summary.txt").write_text(
probe.stdout + probe.stderr, encoding="utf-8")
except (OSError, subprocess.TimeoutExpired):
pass
standalone_projects = standalone / "Faset Café 世界"
standalone_projects.mkdir()
matrix = project_matrix(args.include_lua, args.only_lua)
report = {"format": "faset.playable-export-verification", "version": 1,
"started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform,
"engine": str(engine), "editor": str(editor), "standalone_root": str(standalone),
"engine_revision": source_identity["revision"],
"engine_dirty": source_identity["dirty"],
"engine_git_status": source_identity["status_lines"],
"engine_diff_sha256": source_identity["diff_sha256"],
"editor_sha256": sha256(editor),
"project_workspace": str(workspace),
"vulkaninfo_available": bool(vulkan_summary),
"driver_override": os.environ.get("VK_DRIVER_FILES") or os.environ.get("VK_ICD_FILENAMES"),
"frames_per_game": 120, "status": "running", "projects": [],
"requested_projects": [name for name, _, _ in matrix]}
disabled = workspace / "projects-offline"
"frames_per_game": 120, "status": "running", "projects": []}
disabled = output / "projects-offline"
try:
for name, dimension, language in matrix:
source = engine / "examples" / ("lua" if language == "lua" else f"projects/{name}")
project = projects / name
for dimension in (2, 3):
name = f"collect-{dimension}d"
source, project = engine / "examples/projects" / name, projects / name
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
inputs = [{"path": path.relative_to(project).as_posix(), "sha256": sha256(path)}
for path in sorted(project.rglob("*")) if path.is_file()]
settings = read_json(project / "project.faset.json")
scene = read_json(relative_path(project, settings["start_scene"]))
item = {"name": name, "dimension": dimension, "language": language,
"source_inputs": inputs}
item = {"name": name, "dimension": dimension, "source_inputs": inputs}
report["projects"].append(item)
if dimension == 3:
imported = command(editor, engine, project, "faset_import",
@@ -277,22 +164,18 @@ def main() -> int:
require(generation == Path(exported["result"]["directory"]).resolve(),
"Export response and published pointer disagree")
manifest = verify_package(generation)
if language == "lua":
verify_lua_export(generation, manifest)
require(dimension != 3 or bool(manifest["asset_generations"]),
"3D game did not package its imported Blender asset")
relocated = standalone_projects / name
shutil.copytree(generation, relocated)
verify_package(relocated)
if language == "lua":
verify_lua_export(relocated, manifest)
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
item.update({"generation": pointer["generation"], "configuration": "Release",
"standalone_directory": str(relocated), "executable": manifest["executable"],
"package_file_count": len(manifest["files"]),
"asset_generations": manifest["asset_generations"]})
write_json(output / "report.json", report)
# Hide exactly our disposable source-project paths while launching the games.
# Hide exactly our disposable source-project paths while launching both games.
# An empty, unrelated cwd also catches assumptions about the current directory.
projects.rename(disabled)
working = standalone / "empty-working-directory"
@@ -308,8 +191,6 @@ def main() -> int:
capture, profile = relocated / "verification.ppm", relocated / "profile.json"
rendered = run([player, "--headless", "--frames", "120", "--capture", capture,
"--profile", profile], working, evidence / f"{name}-run.json", 300)
if item["language"] == "lua":
verify_lua_execution(rendered["stderr"])
summary = json.loads(rendered["stdout"].strip().splitlines()[-1])
require(summary["frames"] == 120 and summary["dimension"] == dimension and
summary["validation_errors"] == 0, "Standalone GPU run reported an error")
@@ -320,12 +201,9 @@ def main() -> int:
len(measured["samples"]) == 120, "Invalid or incomplete frame profile")
require(all(frame["gpu_allocated_bytes"] > 0 for frame in measured["samples"]),
"Frame profile lacks Vulkan allocation measurements")
driver = vulkan_device_details(vulkan_summary, measured["device"])
item.update({"device": measured["device"], "device_class": device_class(measured["device"], driver),
"driver": driver, "validation_enabled": measured["validation_enabled"],
item.update({"device": measured["device"], "validation_enabled": measured["validation_enabled"],
"validation_errors": 0, "completed_frames": 120,
"summary_ms": measured["summary_ms"],
"capture": verify_capture(capture, language=item["language"]),
"summary_ms": measured["summary_ms"], "capture": verify_capture(capture),
"source_project_paths_unavailable": True, "status": "passed"})
shutil.copy2(capture, evidence / f"{name}.ppm")
shutil.copy2(profile, evidence / f"{name}-profile.json")
@@ -339,8 +217,7 @@ def main() -> int:
disabled.rename(projects)
report["finished_utc"] = datetime.now(timezone.utc).isoformat()
write_json(output / "report.json", report)
print(f"{len(matrix)} relocated Release game(s) passed: {output / 'report.json'}",
flush=True)
print(f"Both relocated Release games passed: {output / 'report.json'}", flush=True)
return 0