Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e26c2315e | ||
|
|
e074713fed | ||
|
|
d871db1ded | ||
|
|
f490254ab3 | ||
|
|
36a44e14ca | ||
|
|
cfcfdab949 | ||
|
|
674e3e812b |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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 |
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)]]");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user