diff --git a/.gitattributes b/.gitattributes index bce4344..663c17c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,6 @@ licenses/*.txt -whitespace *.ttf binary assets/fonts/OFL.txt -whitespace + +*.blend binary +*.glb binary diff --git a/.github/workflows/windows-graphics.yml b/.github/workflows/windows-graphics.yml index 3b318a2..83a3c5f 100644 --- a/.github/workflows/windows-graphics.yml +++ b/.github/workflows/windows-graphics.yml @@ -7,7 +7,7 @@ permissions: contents: read concurrency: group: windows-graphics-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: windows-graphics: runs-on: windows-2025 @@ -21,8 +21,9 @@ jobs: uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 with: arch: x64 - - name: Cache pinned Vulkan test tools - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + - name: Restore pinned Vulkan test tools + id: vulkan-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 with: key: windows-2025-vulkan-${{ hashFiles('tools/ci/prepare_windows_vulkan.py') }} path: | @@ -30,6 +31,14 @@ jobs: .cache/windows-graphics/driver - name: Build official Vulkan loader and SwiftShader from pinned sources run: python tools/ci/prepare_windows_vulkan.py + - name: Save successfully built Vulkan test tools + if: steps.vulkan-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + key: ${{ steps.vulkan-cache.outputs.cache-primary-key }} + path: | + .cache/windows-graphics/sdk + .cache/windows-graphics/driver - name: Fetch checksum-verified Slang compiler run: python tools/fetch_slang.py - name: Configure full editor and Player @@ -40,6 +49,8 @@ jobs: run: ctest --preset windows-debug --timeout 180 - name: Real Release exports, 2D and 3D execution, incremental Debug rebuild run: build/windows-debug/faset_build_service_tests.exe --integration .cache/windows-export-e2e + - name: Export and relocate both checked-in playable games + run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports - name: Preserve graphics and export evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 @@ -55,3 +66,5 @@ jobs: .cache/windows-export-e2e/result-*.json .cache/windows-export-e2e/export-*/generations/*/manifest.json .cache/windows-export-e2e/export-*/generations/*/verification.ppm + .cache/windows-playable-exports/report.json + .cache/windows-playable-exports/evidence/* diff --git a/.gitignore b/.gitignore index 6fa0d62..07f4ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ CMakeUserPresets.json .env .env.* !.env.example + +# Per-project authoring journals, caches, native builds and exports +**/.faset/ +**/Exports/ +*.blend1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 7fca85d..d164c34 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,6 +63,12 @@ if(TARGET faset_editor_commands AND TARGET faset_build_service) include(cmake/Plugins.cmake) 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) + endif() endif() foreach(module UI EditorUI Editor Applications) if(NOT FASET_BUILD_EDITOR) @@ -83,10 +89,14 @@ if(TARGET faset_editor_session) set_tests_properties(editor_mcp_stdio PROPERTIES TIMEOUT 60) endif() if(TARGET faset_editor_ui) - target_link_libraries(faset_editor PRIVATE faset_editor_ui) + target_link_libraries(faset_editor PRIVATE faset_editor_ui faset_project_launcher) target_link_libraries(faset_editor PRIVATE faset_stb) target_compile_definitions(faset_editor PRIVATE FASET_HAS_EDITOR_UI=1) target_sources(faset_editor PRIVATE apps/editor_gui.cpp) + if(BUILD_TESTING) + add_test(NAME editor_gui_mcp COMMAND ${Python3_EXECUTABLE} ${PROJECT_SOURCE_DIR}/tests/editor_gui_mcp_test.py $) + set_tests_properties(editor_gui_mcp PROPERTIES LABELS "gpu;window" TIMEOUT 180) + endif() endif() endif() diff --git a/README.md b/README.md index 5f99c7a..dd8812d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Faset is an independent engine project for desktop **2D and 3D games on Linux and Windows**. Its priorities are a custom editor that is comfortable to use by hand and through MCP, integration with Blender, and a path toward advanced graphics. -**Current status: MVP implementation is in progress.** The native Editor, shared GUI/MCP authoring, C++ gameplay builds, Vulkan Player, and standalone export are integrated and under acceptance testing. Linux GPU workflows work; complete cross-platform acceptance and the two finished example games remain in progress. See the [implementation checkpoints](docs/IMPLEMENTATION.md) for observed results; a technology appearing in the plan does not mean it is complete or benchmarked. +**Current status: MVP acceptance is in progress.** The native Editor, shared GUI/MCP authoring, C++ gameplay builds, Vulkan Player, standalone export and two playable sample games are integrated. Linux GPU workflows are tested; complete Windows graphics/export acceptance and final validation remain in progress. See the [implementation checkpoints](docs/IMPLEMENTATION.md) for observed results; a technology appearing in the plan does not mean it is complete or benchmarked. ## Start here @@ -12,6 +12,13 @@ Faset is an independent engine project for desktop **2D and 3D games on Linux an - [Documentation](docs/README.md) — navigation and maintenance rules. - [Source studies](docs/studies/README.md) — Unreal Engine, Godot, Unity, Blender, ECS, graphics, asset import, and builds. - [Dependencies and independence](docs/DEPENDENCIES.md) — libraries, tools, and source provenance. +- [Toolchain profiles](docs/TOOLCHAINS.md) — recorded compilers, SDKs, graphics devices and offline preparation. + +After following the manual's build setup, run `build/linux-debug/faset_editor` +(or `build/windows-debug/faset_editor.exe`) to open the project launcher. The +[`collect-2d`](examples/projects/collect-2d) and +[`collect-3d`](examples/projects/collect-3d) projects include playable C++ examples; +the 3D example includes an original Blender asset and import instructions. ## Language diff --git a/apps/editor_gui.cpp b/apps/editor_gui.cpp index 05705a1..e7ed465 100644 --- a/apps/editor_gui.cpp +++ b/apps/editor_gui.cpp @@ -54,6 +54,7 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames, const auto font = session.config().engine_root / "assets/fonts/NotoSans.ttf"; const auto theme = session.config().engine_root / "assets/ui/dark.json"; EditorUI ui(session, renderer, font, theme); + ui.set_project_switch_enabled(!enable_mcp); McpServer server(session.commands()); StdioTransport transport; session.commands().add( @@ -75,6 +76,8 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames, auto encoded = png(renderer, region); const auto relative = arguments.value("path", std::string(".faset/screenshots/editor.png")); + require(std::filesystem::path(relative).extension() == ".png", "capture.path", + "Editor screenshots must use a .png path"); atomic_write( project_path(session.config().project_root, relative), std::string_view(reinterpret_cast(encoded.data()), encoded.size())); @@ -100,6 +103,8 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames, session.poll(); ui.frame(renderer.poll_events()); renderer.render(ui.snapshot()); + if (ui.project_switch_requested()) + return 3; // Application-level request: destroy this Session before opening another. ++frame; std::this_thread::sleep_for(std::chrono::milliseconds(1)); } diff --git a/apps/editor_main.cpp b/apps/editor_main.cpp index 547a437..2a91a31 100644 --- a/apps/editor_main.cpp +++ b/apps/editor_main.cpp @@ -7,6 +7,7 @@ #include #ifdef FASET_HAS_EDITOR_UI #include +#include namespace faset::editor { int run_editor_ui(Session&, bool, std::uint64_t, const std::filesystem::path&); } @@ -38,6 +39,7 @@ std::filesystem::path executable_directory(const char* argument) { void help() { std::cout << "Faset Editor\n" + " faset_editor Open the project launcher\n" " faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n" " faset_editor --project PATH --mcp [--gui]\n" " faset_editor --project PATH --command JSON [--wait]\n" @@ -97,71 +99,102 @@ int main(int argc, char** argv) { else throw Error("cli.option", "Unknown option: " + arg); } - require(!project.empty(), "cli.project", "Use --project PATH to select a project"); require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command"); if (mcp && !explicit_gui) gui = false; - 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(); - if (scene.empty()) - scene = settings.value("start_scene", std::string()); - if (!scene.empty() && - std::filesystem::exists(project_path(session.config().project_root, scene))) - session.authoring().open(scene); - if (!command.empty()) { - const auto request = Json::parse(command); - auto result = session.commands().call(request.at("name"), - request.value("arguments", Json::object())); - if (wait && result.contains("job")) { - const auto id = result.at("job"); - const auto started = std::chrono::steady_clock::now(); - for (;;) { - session.poll(); - result = session.commands().call("faset_job", {{"id", id}}); - const auto state = result.value("state", std::string()); - if (state == "succeeded" || state == "failed" || state == "cancelled" || - state == "conflict") - break; - require(std::chrono::steady_clock::now() - started < std::chrono::minutes(30), - "job.timeout", "Command wait exceeded 30 minutes"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); - } - } - std::cout << result.dump(2) << '\n'; - const auto state = result.value("state", std::string()); - return state == "failed" || state == "cancelled" || state == "conflict" ? 1 : 0; - } - if (gui) { + require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project", + "Use --project PATH for command, MCP, or --new modes"); + auto previous_project = project; + for (;;) { + if (project.empty()) { #ifdef FASET_HAS_EDITOR_UI - return run_editor_ui(session, mcp, frames, capture); + const auto selection = + 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; #else - throw Error("editor.gui_unavailable", - "This build has no graphical editor; use --mcp or --command, or build with " - "FASET_BUILD_EDITOR=ON"); + throw Error("editor.gui_unavailable", + "This build has no graphical project launcher; use --project PATH"); #endif - } - require(mcp, "cli.mode", "Headless mode requires --mcp or --command"); - std::signal(SIGINT, interrupt); - std::signal(SIGTERM, interrupt); - McpServer server(session.commands()); - StdioTransport transport; - while (!interrupted && !transport.closed()) { - for (const auto& line : transport.poll()) { - try { - const auto reply = server.handle(Json::parse(line)); - if (reply) - transport.send(*reply); - } catch (const Json::exception&) { - transport.send(server.parse_error()); - } } - session.poll(); - std::this_thread::sleep_for(std::chrono::milliseconds(5)); + 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")) + remember_project(project); +#endif + if (scene.empty()) + scene = settings.value("start_scene", std::string()); + if (!scene.empty() && + std::filesystem::exists(project_path(session.config().project_root, scene))) + session.authoring().open(scene); + if (!command.empty()) { + const auto request = Json::parse(command); + auto result = session.commands().call(request.at("name"), + request.value("arguments", Json::object())); + if (wait && result.contains("job")) { + const auto id = result.at("job"); + const auto started = std::chrono::steady_clock::now(); + for (;;) { + session.poll(); + result = session.commands().call("faset_job", {{"id", id}}); + const auto state = result.value("state", std::string()); + if (state == "succeeded" || state == "failed" || state == "cancelled" || + state == "conflict") + break; + require(std::chrono::steady_clock::now() - started < + std::chrono::minutes(30), + "job.timeout", "Command wait exceeded 30 minutes"); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + } + std::cout << result.dump(2) << '\n'; + const auto state = result.value("state", std::string()); + return state == "failed" || state == "cancelled" || state == "conflict" ? 1 : 0; + } + if (gui) { +#ifdef FASET_HAS_EDITOR_UI + const int result = run_editor_ui(session, mcp, frames, capture); + if (result != 3) + return result; + previous_project = project; + project.clear(); + scene.clear(); + new_name.clear(); + continue; +#else + throw Error( + "editor.gui_unavailable", + "This build has no graphical editor; use --mcp or --command, or build with " + "FASET_BUILD_EDITOR=ON"); +#endif + } + require(mcp, "cli.mode", "Headless mode requires --mcp or --command"); + std::signal(SIGINT, interrupt); + std::signal(SIGTERM, interrupt); + McpServer server(session.commands()); + StdioTransport transport; + while (!interrupted && !transport.closed()) { + for (const auto& line : transport.poll()) { + try { + const auto reply = server.handle(Json::parse(line)); + if (reply) + transport.send(*reply); + } catch (const Json::exception&) { + transport.send(server.parse_error()); + } + } + session.poll(); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return 0; } - return 0; } catch (const Error& error) { std::cerr << error.json().dump() << '\n'; return 1; diff --git a/apps/player_main.cpp b/apps/player_main.cpp index ec5c60d..46d8bd5 100644 --- a/apps/player_main.cpp +++ b/apps/player_main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -9,12 +10,103 @@ #include #include #include +#include #if defined(_WIN32) #define NOMINMAX #include #endif namespace { +using Clock = std::chrono::steady_clock; +using Json = nlohmann::json; +double milliseconds(Clock::time_point begin, Clock::time_point end) { + return std::chrono::duration(end - begin).count(); +} +struct ProfileSample { + double wall{}, simulation{}, snapshot{}, render{}, rendererCpu{}, gpu{}, readbackCpu{}; + faset::runtime::FrameStats runtime; + std::uint32_t draws{}, vertices{}; + std::uint64_t gpuAllocatedBytes{}; + std::uint32_t textureCount{}; + bool physicsDebug{}; +}; +Json distribution(std::vector values) { + if (values.empty()) + return nullptr; + std::sort(values.begin(), values.end()); + auto percentile = [&](double fraction) { + return values[static_cast(std::ceil(fraction * values.size())) - 1]; + }; + return {{"samples", values.size()}, + {"min", values.front()}, + {"p50", percentile(.5)}, + {"p95", percentile(.95)}, + {"max", values.back()}}; +} +Json profileFrames(const std::vector& samples) { + Json frames = Json::array(); + std::vector wall, simulation, snapshot, render, rendererCpu, gpu, readbackCpu; + for (const auto& sample : samples) { + wall.push_back(sample.wall); + simulation.push_back(sample.simulation); + snapshot.push_back(sample.snapshot); + render.push_back(sample.render); + rendererCpu.push_back(sample.rendererCpu); + readbackCpu.push_back(sample.readbackCpu); + // The backend reports zero if timestamp queries are unavailable. Do not + // present that sentinel as a measured zero-cost GPU frame. + const bool gpuMeasured = std::isfinite(sample.gpu) && sample.gpu > 0; + if (gpuMeasured) + gpu.push_back(sample.gpu); + frames.push_back({{"frame", frames.size() + 1}, + {"wall_ms", sample.wall}, + {"simulation_ms", sample.simulation}, + {"snapshot_ms", sample.snapshot}, + {"render_call_ms", sample.render}, + {"renderer_cpu_ms", sample.rendererCpu}, + {"renderer_readback_cpu_ms", sample.readbackCpu}, + {"gpu_ms", gpuMeasured ? Json(sample.gpu) : Json(nullptr)}, + {"fixed_ticks", sample.runtime.fixedTicks}, + {"tick", sample.runtime.tick}, + {"dropped_simulation_seconds", sample.runtime.droppedTime}, + {"interpolation_alpha", sample.runtime.interpolationAlpha}, + {"draw_calls", sample.draws}, + {"vertices", sample.vertices}, + {"gpu_allocated_bytes", sample.gpuAllocatedBytes}, + {"texture_count", sample.textureCount}, + {"physics_debug", sample.physicsDebug}}); + } + return {{"samples", std::move(frames)}, + {"summary_ms", + {{"wall", distribution(std::move(wall))}, + {"simulation", distribution(std::move(simulation))}, + {"snapshot", distribution(std::move(snapshot))}, + {"render_call", distribution(std::move(render))}, + {"renderer_cpu", distribution(std::move(rendererCpu))}, + {"renderer_readback_cpu", distribution(std::move(readbackCpu))}, + {"gpu", distribution(std::move(gpu))}}}}; +} +Json physicsScene(const faset::runtime::Runtime& world, const Json& presentation) { + const int dimension = presentation.at("dimension"); + const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d"; + Json result{{"dimension", dimension}, {"entities", Json::array()}}; + for (const auto& entity : presentation.at("entities")) { + const auto handle = world.find(entity.at("id").get()); + Json body; + try { + body = world.fields(handle, "faset." + bodyName); + } catch (const std::invalid_argument&) { + continue; // Render-only entities do not own a physics component. + } + const auto pose = world.transform(handle); // Current physics pose, not interpolation. + result["entities"].push_back( + {{"parent", nullptr}, + {"transform", + {{"position", pose.position}, {"rotation", pose.rotation}, {"scale", pose.scale}}}, + {bodyName, std::move(body)}}); + } + return result; +} std::filesystem::path executableDirectory(const char* argument) { #if defined(_WIN32) std::wstring path(32768, L'\0'); @@ -70,31 +162,29 @@ faset::runtime::RuntimeConfig simulationConfig(const nlohmann::json& scene) { return config; } void validatePackagedShaders(const std::filesystem::path& directory) { - for (const auto* name : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) { - const auto path = directory / "shaders" / name; - if (!std::filesystem::is_regular_file(path)) - throw std::runtime_error("Packaged shader is missing: " + path.string()); - const auto bytes = faset::read_text(path); - if (bytes.size() < 20 || bytes.size() % 4 != 0 || - static_cast(bytes[0]) != 0x03 || - static_cast(bytes[1]) != 0x02 || - static_cast(bytes[2]) != 0x23 || - static_cast(bytes[3]) != 0x07) - throw std::runtime_error("Packaged shader is not a SPIR-V module: " + path.string()); - } + const auto shaders = directory / "shaders"; + for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"}) + for (const auto* extension : {".spv", ".reflection.json"}) { + const auto path = shaders / (std::string(entry) + extension); + if (!std::filesystem::is_regular_file(path)) + throw std::runtime_error("Packaged shader file is missing: " + path.string()); + } + faset::render::validate_shader_bundle(shaders); } } // namespace int main(int argc, char** argv) { + const auto started = Clock::now(); try { - std::filesystem::path scenePath, assetsPath, capturePath, controlPath; - bool headless = false, validateOnly = false; + std::filesystem::path scenePath, assetsPath, capturePath, controlPath, profilePath; + bool headless = false, validateOnly = false, debugPhysics = false; std::uint64_t maximumFrames = 0; std::set options; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; if (arg == "--help") { std::cout << "faset_player [--scene PATH] [--assets CACHE] [--frames N] " - "[--headless] [--capture PATH.ppm] [--validate] [--control PATH]\n" + "[--headless] [--capture PATH.ppm] [--validate] [--control PATH] " + "[--profile PATH.json] [--debug-physics]\n" "No --scene: open scene.fscene beside the executable. CACHE contains " "assets//.\n" "Headless uses offscreen Vulkan; --frames uses the configured fixed " @@ -103,8 +193,10 @@ int main(int argc, char** argv) { "or Vulkan initialization.\n" "--control is an optional editor mailbox for pause/resume/step/stop, " "without world queries.\n" + "--profile requires explicit --frames 1..100000; measured durations " + "include the first frame and renderer GPU waits/readback.\n" "Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, " - "N single-step, Escape quit.\n"; + "N single-step, F3 physics boxes, Escape quit.\n"; return 0; } if (!options.insert(arg).second) @@ -122,15 +214,24 @@ int main(int argc, char** argv) { capturePath = value(); else if (arg == "--control") controlPath = value(); + else if (arg == "--profile") + profilePath = value(); else if (arg == "--frames") maximumFrames = count(value()); else if (arg == "--headless") headless = true; else if (arg == "--validate") validateOnly = true; + else if (arg == "--debug-physics") + debugPhysics = true; else throw std::invalid_argument("Unknown option: " + arg); } + if (options.contains("--profile") && + (profilePath.empty() || !options.contains("--frames") || maximumFrames > 100000 || + validateOnly)) + throw std::invalid_argument("--profile requires an output path and explicit --frames " + "1..100000, without --validate"); if (scenePath.empty()) scenePath = executableDirectory(argv[0]) / "scene.fscene"; scenePath = std::filesystem::absolute(scenePath).lexically_normal(); @@ -141,8 +242,10 @@ int main(int argc, char** argv) { assetsPath.string()); if (headless && maximumFrames == 0) maximumFrames = 1; + const auto sceneReadStarted = Clock::now(); const auto document = faset::player::readScene(scenePath); const auto config = simulationConfig(document); + const auto sceneReadFinished = Clock::now(); const auto executableRoot = executableDirectory(argv[0]); if (scenePath.extension() == ".fscene" && std::filesystem::equivalent(scenePath.parent_path(), executableRoot)) @@ -165,12 +268,19 @@ int main(int argc, char** argv) { << '\n'; return 0; } + const auto worldStarted = Clock::now(); faset::runtime::Runtime world(config); faset::gameplay::registerGameplay(world); world.load(document); faset::player::SceneView view(assetsPath); + const auto rendererStarted = Clock::now(); faset::render::Renderer renderer( {1280, 720, document.value("name", std::string("Faset Player")), headless, true}); + const auto rendererReady = Clock::now(); + std::vector profile; + if (!profilePath.empty()) + profile.reserve(static_cast(maximumFrames)); + Json firstFrameMs = nullptr; std::set held; bool stop = false; std::uint64_t frames = 0; @@ -181,6 +291,7 @@ int main(int argc, char** argv) { auto previous = std::chrono::steady_clock::now(); while (!stop && !renderer.should_close() && (maximumFrames == 0 || frames < maximumFrames)) { + const auto frameStarted = Clock::now(); faset::runtime::InputState input; bool singleStep = false; if (!controlPath.empty() && std::filesystem::is_regular_file(controlPath)) { @@ -244,6 +355,8 @@ int main(int argc, char** argv) { world.setPaused(!world.paused()); if (key == "N") singleStep = true; + if (key == "F3") + debugPhysics = !debugPhysics; } } } @@ -258,12 +371,16 @@ int main(int argc, char** argv) { ? config.fixedDelta : std::chrono::duration(now - previous).count(); previous = now; - if (singleStep && world.paused()) - world.singleStep(input); - else - world.advance(elapsed, input); - auto snapshot = view.build(world.snapshotJson(), static_cast(renderer.width()) / - std::max(1u, renderer.height())); + const auto simulationStarted = Clock::now(); + const auto runtimeStats = singleStep && world.paused() ? world.singleStep(input) + : world.advance(elapsed, input); + const auto simulationFinished = Clock::now(); + const auto presentation = world.snapshotJson(); + auto snapshot = view.build(presentation, static_cast(renderer.width()) / + std::max(1u, renderer.height())); + if (debugPhysics) + view.appendPhysicsDebug(snapshot, physicsScene(world, presentation)); + const auto snapshotFinished = Clock::now(); for (const auto& diagnostic : view.diagnostics()) { if (diagnostic.starts_with("error:")) throw std::runtime_error(diagnostic); @@ -272,7 +389,21 @@ int main(int argc, char** argv) { } while (logCursor < world.diagnostics().size()) std::cerr << world.diagnostics()[logCursor++] << '\n'; + const auto renderStarted = Clock::now(); renderer.render(snapshot); + const auto frameFinished = Clock::now(); + if (frames == 0) + firstFrameMs = milliseconds(started, frameFinished); + if (!profilePath.empty()) { + const auto measured = renderer.stats(); + profile.push_back( + {milliseconds(frameStarted, frameFinished), + milliseconds(simulationStarted, simulationFinished), + milliseconds(simulationFinished, snapshotFinished), + milliseconds(renderStarted, frameFinished), measured.cpu_ms, measured.gpu_ms, + measured.readback_cpu_ms, runtimeStats, measured.draw_calls, measured.vertices, + measured.gpu_allocated_bytes, measured.texture_count, debugPhysics}); + } ++frames; } if (!capturePath.empty()) { @@ -281,6 +412,41 @@ int main(int argc, char** argv) { renderer.capture(capturePath); } const auto stats = renderer.stats(); + if (!profilePath.empty()) { + auto report = profileFrames(profile); + report.update( + {{"format", "faset.player-profile"}, + {"version", 1}, + {"requested_frames", maximumFrames}, + {"completed_frames", frames}, + {"dimension", document.value("dimension", 3)}, + {"device", stats.device}, + {"width", renderer.width()}, + {"height", renderer.height()}, + {"validation_enabled", stats.validation_enabled}, + {"validation_errors", stats.validation_errors}, + {"presentation_mode", headless ? "offscreen" : "windowed"}, + {"simulation_mode", "synthetic_fixed_timestep"}, + {"fixed_delta_seconds", config.fixedDelta}, + {"percentile_method", "nearest_rank_all_completed_frames_no_warmup_exclusion"}, + {"resource_notes", "gpu_allocated_bytes sums live Vulkan memory allocations, " + "including alignment, excluding driver internals. " + "texture_count includes the white fallback texture."}, + {"timing_notes", + "Durations use steady_clock wall time, not thread CPU usage. " + "render_call/renderer_cpu include GPU waits, readback and window presentation. " + "renderer_readback_cpu measures map/copy/unmap wall time within that call. " + "GPU timestamps cover submitted rendering, not CPU work. A missing/zero GPU " + "timestamp is null. Frame durations exclude profile bookkeeping and final " + "capture/profile file writes. Startup begins at main(), excluding OS " + "loader/launcher."}, + {"startup_ms", + {{"scene_read", milliseconds(sceneReadStarted, sceneReadFinished)}, + {"world_initialization", milliseconds(worldStarted, rendererStarted)}, + {"renderer_initialization", milliseconds(rendererStarted, rendererReady)}, + {"main_to_first_frame", firstFrameMs}}}}); + faset::atomic_write_json(profilePath, report); + } std::cout << nlohmann::json{{"frames", frames}, {"ticks", world.snapshot().tick}, {"dimension", document.value("dimension", 3)}, diff --git a/apps/project_launcher.cpp b/apps/project_launcher.cpp new file mode 100644 index 0000000..e73ff9b --- /dev/null +++ b/apps/project_launcher.cpp @@ -0,0 +1,435 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::editor { +namespace { +namespace fs = std::filesystem; +using ui::Kind; +using ui::Widget; +fs::path user_home() { +#ifdef _WIN32 + if (const auto* value = std::getenv("USERPROFILE")) + return fs::path(value); +#else + if (const auto* value = std::getenv("HOME")) + return fs::path(value); +#endif + return fs::current_path(); +} +fs::path recent_path() { +#ifdef _WIN32 + if (const auto* value = std::getenv("APPDATA")) + return fs::path(value) / "Faset/recent-projects.json"; +#else + if (const auto* value = std::getenv("XDG_CONFIG_HOME"); value && *value) + return fs::path(value) / "faset/recent-projects.json"; +#endif + return user_home() / ".config/faset/recent-projects.json"; +} +std::string trimmed(std::string text) { + const auto first = text.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) + return {}; + return text.substr(first, text.find_last_not_of(" \t\r\n") - first + 1); +} +fs::path normalize(std::string text) { + text = trimmed(std::move(text)); + if (text.empty()) + throw std::runtime_error("Enter a project directory."); + if (text.find_first_of("\r\n\t") != std::string::npos || text.find('\0') != std::string::npos) + throw std::runtime_error("A directory must fit on one line."); + fs::path path = fs::u8path(text); + if (text == "~") + path = user_home(); + else if (text.starts_with("~/") || text.starts_with("~\\")) + path = user_home() / fs::u8path(text.substr(2)); + path = fs::weakly_canonical(fs::absolute(path)); + if (path.filename() == "project.faset.json") + path = path.parent_path(); + return path; +} +std::string utf8(const fs::path& path) { + const auto value = path.u8string(); + return std::string(reinterpret_cast(value.data()), value.size()); +} +ProjectSelection open_project(const fs::path& path) { + if (!fs::is_directory(path)) + throw std::runtime_error("Project directory does not exist."); + if (!fs::is_regular_file(path / "project.faset.json")) + throw std::runtime_error("No project.faset.json in this directory."); + Json data; + try { + data = read_json(path / "project.faset.json"); + } catch (...) { + throw std::runtime_error("Cannot read project.faset.json."); + } + if (!data.is_object() || data.value("format", std::string()) != "faset.project" || + data.value("version", 0) != 1) + throw std::runtime_error("Unsupported project format or version."); + auto name = data.value("name", std::string()); + const auto dimension = data.value("dimension", 3); + if (name.empty() || (dimension != 2 && dimension != 3)) + throw std::runtime_error("Project name or scene type is invalid."); + return {path, std::move(name), dimension, false}; +} +Json recent_records(const fs::path& file) { + try { + auto j = read_json(file); + return j.is_array() ? j : Json::array(); + } catch (...) { + return Json::array(); + } +} +void remember(const fs::path& project, const fs::path& store) { + const auto selected = open_project(normalize(utf8(project))); + auto records = recent_records(store); + std::erase_if(records.get_ref(), [&](const Json& record) { + return !record.is_string() || record.get() == utf8(selected.path); + }); + records.insert(records.begin(), utf8(selected.path)); + while (records.size() > 12) + records.erase(records.end() - 1); + atomic_write_json(store, records); +} +Widget& button(Widget& parent, const std::string& id, const std::string& text, + std::function action) { + auto& w = parent.add(Kind::Button, id, text); + w.on_click = [action = std::move(action)](Widget&) { action(); }; + return w; +} +} // namespace + +void remember_project(const fs::path& project) { + try { + remember(project, recent_path()); + } catch (...) { /* Recent history is not required to open a project. */ + } +} +struct ProjectLauncher::Impl { + render::Renderer& renderer; + ui::Context ui; + render::Snapshot frame_data; + fs::path recents_file, browse_path; + std::optional pending_browse; + std::optional selected; + bool create = false, cancelled = false, browsing = false, browser_valid = false; + int dimension = 3; + std::string error; + Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial, + 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"); + theme.font_size = 15; + theme.row_height = 30; + ui.set_theme(theme); + ui.apply_layout(read_json(engine / "assets/ui/project-launcher-layout.json")); + ui.set_clipboard([this] { return renderer.clipboard(); }, + [this](const std::string& value) { renderer.set_clipboard(value); }); + ui.set_ime( + [this](bool enabled) { renderer.set_text_input(enabled); }, + [this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); }); + const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial; + ui.update_text("launcher-path", utf8(start)); + 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-submit")->selected = true; + 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(); }; + ui.find("launcher-path")->on_preview = [this](Widget&) { error.clear(); }; + ui.find("launcher-browse")->on_click = [this](Widget&) { show_browser(); }; + build_browser(); + load_recents(); + refresh(); + ui.layout(float(renderer.width()), float(renderer.height())); + ui.focus(create ? "launcher-name" : "launcher-path"); + } + void set_mode(bool value) { + ui.clear_focus(); + create = value; + error.clear(); + refresh(); + ui.focus(create ? "launcher-name" : "launcher-path"); + } + void load_recents() { + auto& list = *ui.find("launcher-recents"); + std::size_t i = 0; + std::set paths; + for (const auto& record : recent_records(recents_file)) { + if (!record.is_string()) + continue; + try { + const auto selection = open_project(normalize(record.get())); + if (!paths.insert(selection.path).second) + continue; + const auto id = "launcher-recent-" + std::to_string(i++); + auto& item = list.add(Kind::TreeRow, id, selection.name); + item.layout.height = 40; + item.tooltip = utf8(selection.path); + item.on_click = [this, path = selection.path](Widget&) { + ui.update_text("launcher-path", utf8(path), true); + set_mode(false); + }; + } catch (...) { + } + } + if (i == 0) + list.add(Kind::Label, "launcher-recents-empty", "No recent projects"); + } + void submit() { + ui.clear_focus(); + if (ui.editing()) + return; + try { + const auto path = normalize(ui.find("launcher-path")->text); + if (create) { + const auto name = trimmed(ui.find("launcher-name")->text); + if (name.empty()) + throw std::runtime_error("Enter a project name."); + if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos) + throw std::runtime_error("Use a short project name on one line."); + if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(path))) + 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}; + } else + selected = open_project(path); + error.clear(); + } catch (const fs::filesystem_error&) { + error = "Cannot access this directory."; + } catch (const Json::exception&) { + error = "Invalid project.faset.json metadata."; + } catch (const std::exception& e) { + error = e.what(); + } + } + void build_browser() { + auto& panel = ui.root().add(Kind::Panel, "launcher-browser"); + panel.layout.absolute = true; + panel.layout.padding = 16; + panel.layout.gap = 10; + panel.visible = false; + auto& title = panel.add(Kind::Label, "browser-heading", "Choose project directory"); + title.font_size = 20; + title.layout.height = 35; + auto& nav = panel.add(Kind::Row, "browser-navigation"); + nav.layout.height = 36; + button(nav, "browser-up", "Up", [this] { + browse_to(browse_path.parent_path()); + }).layout.width = 60; + button(nav, "browser-home", "Home", [this] { browse_to(user_home()); }).layout.width = 76; + auto& path = nav.add(Kind::TextField, "browser-path"); + path.layout.flex = 1; + path.on_preview = [this](Widget&) { browser_valid = false; }; + path.on_commit = [this](Widget& w) { + try { + pending_browse = normalize(w.text); + } catch (const std::exception& e) { + error = e.what(); + } + }; + auto& list = panel.add(Kind::Column, "browser-list"); + list.layout.flex = 1; + list.layout.scroll = true; + list.layout.gap = 2; + auto& actions = panel.add(Kind::Row, "browser-actions"); + actions.layout.height = 36; + button(actions, "browser-choose", "Choose directory", [this] { + if (browser_valid) { + ui.update_text("launcher-path", utf8(browse_path), true); + close_browser(); + } + }).layout.width = 180; + button(actions, "browser-cancel", "Cancel", [this] { close_browser(); }).layout.width = 90; + panel.add(Kind::Label, "browser-error"); + } + void browse_to(const fs::path& path) { + browser_valid = false; + try { + const auto normalized = fs::weakly_canonical(fs::absolute(path)); + if (!fs::is_directory(normalized)) + throw std::runtime_error("Directory does not exist."); + std::vector children; + for (const auto& entry : fs::directory_iterator( + normalized, fs::directory_options::skip_permission_denied)) { + std::error_code status; + if (entry.is_directory(status)) + children.push_back(entry.path()); + } + std::sort(children.begin(), children.end()); + browse_path = normalized; + error.clear(); + browser_valid = true; + auto& list = *ui.find("browser-list"); + list.children.clear(); + list.scroll_y = 0; + ui.update_text("browser-path", utf8(browse_path), true); + std::size_t index = 0; + for (const auto& child : children) { + auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++), + "[Folder] " + utf8(child.filename())); + row.layout.height = 34; + row.on_click = [this, child](Widget&) { + ui.clear_focus(); + browse_to(child); + }; + } + if (children.empty()) + list.add(Kind::Label, "browser-empty", "No subdirectories"); + } catch (const fs::filesystem_error&) { + error = "Cannot access this directory."; + } catch (const std::exception& e) { + error = e.what(); + } + } + void show_browser() { + ui.clear_focus(); + fs::path path = user_home(); + try { + path = normalize(ui.find("launcher-path")->text); + while (!path.empty() && !fs::exists(path)) + path = path.parent_path(); + if (!fs::is_directory(path)) + path = path.parent_path(); + } catch (...) { + } + browsing = true; + refresh(); + browse_to(path); + ui.focus("browser-path"); + } + void close_browser() { + ui.clear_focus(false); + browsing = false; + error.clear(); + refresh(); + ui.focus("launcher-path"); + } + void navigate_pending() { + if (pending_browse) { + const auto path = *pending_browse; + pending_browse.reset(); + browse_to(path); + } + } + void refresh() { + ui.find("launcher-open")->selected = !create; + ui.find("launcher-create")->selected = create; + ui.find("launcher-2d")->selected = dimension == 2; + ui.find("launcher-3d")->selected = dimension == 3; + for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label", + "launcher-dimensions"}) + 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"; + ui.find("launcher-hint")->text = create ? "2D and 3D can share one project." + : "Open a directory containing project.faset.json."; + ui.find("launcher-error")->text = browsing ? "" : error; + ui.find("browser-error")->text = error; + ui.find("browser-choose")->enabled = browser_valid; + ui.find("launcher-body")->enabled = !browsing; + auto* dialog = ui.find("launcher-browser"); + dialog->visible = browsing; + dialog->layout.width = std::max(320.f, std::min(760.f, float(renderer.width()) - 40)); + dialog->layout.height = std::max(300.f, std::min(540.f, float(renderer.height()) - 40)); + dialog->layout.x = (float(renderer.width()) - dialog->layout.width) * .5f; + dialog->layout.y = (float(renderer.height()) - dialog->layout.height) * .5f; + ui.find("launcher-sidebar")->layout.width = + std::clamp(float(renderer.width()) * .24f, 180.f, 235.f); + ui.find("launcher-main")->layout.padding = renderer.width() < 850 ? 16 : 32; + } + void frame(const std::vector& events) { + refresh(); + ui.layout(float(renderer.width()), float(renderer.height())); + for (const auto& event : events) { + if (event.type == render::Event::Type::Quit) + cancelled = true; + if (event.type == render::Event::Type::KeyDown && event.key == "Escape") { + if (browsing) + close_browser(); + else + cancelled = true; + continue; + } + if (event.type == render::Event::Type::KeyDown && event.control && + event.key == "Return") { + if (browsing) { + ui.clear_focus(); + navigate_pending(); + if (!ui.editing() && browser_valid) { + ui.update_text("launcher-path", utf8(browse_path), true); + close_browser(); + } + } else + submit(); + continue; + } + if (browsing && event.type == render::Event::Type::MouseDown && + !ui.find("launcher-browser")->rect.contains(event.x, event.y)) + continue; + ui.handle(event); + navigate_pending(); + refresh(); + ui.layout(float(renderer.width()), float(renderer.height())); + } + refresh(); + ui.layout(float(renderer.width()), float(renderer.height())); + frame_data = {}; + frame_data.clear_color = ui.theme().background; + ui.draw(frame_data); + } +}; +ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine, + const fs::path& initial, const fs::path& recents) + : impl_(std::make_unique(r, engine, initial, recents)) {} +ProjectLauncher::~ProjectLauncher() = default; +void ProjectLauncher::frame(const std::vector& events) { + impl_->frame(events); +} +const render::Snapshot& ProjectLauncher::snapshot() const { + return impl_->frame_data; +} +ui::Context& ProjectLauncher::widgets() { + return impl_->ui; +} +const std::optional& ProjectLauncher::selection() const { + return impl_->selected; +} +bool ProjectLauncher::cancelled() const { + return impl_->cancelled; +} +std::optional run_project_launcher(const fs::path& engine, + const fs::path& initial, + std::uint64_t max_frames, + const fs::path& capture) { + render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true}); + ProjectLauncher launcher(renderer, engine, initial); + std::uint64_t frames = 0; + while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() && + (max_frames == 0 || frames < max_frames)) { + launcher.frame(renderer.poll_events()); + renderer.render(launcher.snapshot()); + ++frames; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (!capture.empty()) + renderer.capture(capture); + if (renderer.stats().validation_errors) + throw std::runtime_error("Project launcher Vulkan validation failed"); + return launcher.selection(); +} +} // namespace faset::editor diff --git a/assets/ui/project-launcher-layout.json b/assets/ui/project-launcher-layout.json new file mode 100644 index 0000000..15f9789 --- /dev/null +++ b/assets/ui/project-launcher-layout.json @@ -0,0 +1,40 @@ +{ + "root": { + "id": "root", + "layout": {"gap": 0}, + "children": [{ + "id": "launcher-body", "kind": "row", "layout": {"flex": 1, "gap": 0}, + "children": [ + {"id": "launcher-sidebar", "kind": "panel", "layout": {"width": 235, "padding": 16, "gap": 8}, "children": [ + {"id": "launcher-wordmark", "kind": "label", "text": "Faset", "font_size": 26, "layout": {"height": 66}}, + {"id": "launcher-open", "kind": "tree_row", "text": "Open project", "layout": {"height": 40}}, + {"id": "launcher-create", "kind": "tree_row", "text": "Create project", "layout": {"height": 40}}, + {"id": "launcher-recent-title", "kind": "label", "text": "Recent projects", "layout": {"height": 54}}, + {"id": "launcher-recents", "kind": "column", "layout": {"flex": 1, "scroll": true, "gap": 6}} + ]}, + {"id": "launcher-main", "kind": "column", "layout": {"flex": 1, "padding": 32, "gap": 12, "scroll": true}, "children": [ + {"id": "launcher-heading", "kind": "label", "text": "Open project", "font_size": 26, "layout": {"height": 64}}, + {"id": "launcher-name-label", "kind": "label", "text": "Project name"}, + {"id": "launcher-name", "kind": "text_field", "text": "My Game", "layout": {"height": 40}}, + {"id": "launcher-path-label", "kind": "label", "text": "Project directory"}, + {"id": "launcher-path-row", "kind": "row", "layout": {"height": 40, "gap": 8}, "children": [ + {"id": "launcher-path", "kind": "text_field", "layout": {"flex": 1}}, + {"id": "launcher-browse", "kind": "button", "text": "Browse", "layout": {"width": 84}} + ]}, + {"id": "launcher-dimension-label", "kind": "label", "text": "Initial scene type"}, + {"id": "launcher-dimensions", "kind": "row", "layout": {"height": 40, "gap": 0}, "children": [ + {"id": "launcher-2d", "kind": "tab", "text": "2D", "layout": {"width": 120}}, + {"id": "launcher-3d", "kind": "tab", "text": "3D", "layout": {"width": 120}} + ]}, + {"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "layout": {"height": 36}}, + {"id": "launcher-actions", "kind": "row", "layout": {"height": 40, "gap": 8}, "children": [ + {"id": "launcher-submit", "kind": "button", "text": "Open project", "layout": {"width": 180}}, + {"id": "launcher-cancel", "kind": "button", "text": "Cancel", "layout": {"width": 90}} + ]}, + {"id": "launcher-error", "kind": "label", "layout": {"height": 30}}, + {"id": "launcher-shortcuts", "kind": "label", "text": "Tab: next field Ctrl+Enter: continue Escape: cancel"} + ]} + ] + }] + } +} diff --git a/cmake/Assets.cmake b/cmake/Assets.cmake index 29ea1d0..80550a8 100644 --- a/cmake/Assets.cmake +++ b/cmake/Assets.cmake @@ -8,6 +8,10 @@ add_library(faset_assets target_compile_features(faset_assets PUBLIC cxx_std_20) target_include_directories(faset_assets PUBLIC ${PROJECT_SOURCE_DIR}/include) target_link_libraries(faset_assets PUBLIC faset_asset_data PRIVATE faset_cgltf faset_stb) +string(JSON FASET_CGLTF_COMMIT GET "${FASET_DEPENDENCY_LOCK}" dependencies cgltf commit) +string(JSON FASET_STB_COMMIT GET "${FASET_DEPENDENCY_LOCK}" dependencies stb commit) +target_compile_definitions(faset_assets PRIVATE + FASET_CGLTF_COMMIT="${FASET_CGLTF_COMMIT}" FASET_STB_COMMIT="${FASET_STB_COMMIT}") if(BUILD_TESTING) add_executable(faset_assets_tests ${PROJECT_SOURCE_DIR}/tests/assets_pipeline.cpp) target_link_libraries(faset_assets_tests PRIVATE faset_assets) diff --git a/cmake/EditorUI.cmake b/cmake/EditorUI.cmake index 3f7fddd..f15780d 100644 --- a/cmake/EditorUI.cmake +++ b/cmake/EditorUI.cmake @@ -1,7 +1,24 @@ if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view) add_library(faset_editor_ui STATIC ${PROJECT_SOURCE_DIR}/src/editor/editor_ui.cpp) target_link_libraries(faset_editor_ui PUBLIC faset_ui faset_editor_session faset_scene_view) + add_library(faset_project_launcher STATIC ${PROJECT_SOURCE_DIR}/apps/project_launcher.cpp) + target_link_libraries(faset_project_launcher PUBLIC faset_ui faset_render) if(BUILD_TESTING) + add_executable(faset_editor_project_settings_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_project_settings.cpp) + target_link_libraries(faset_editor_project_settings_ui_tests PRIVATE faset_editor_ui) + target_compile_definitions(faset_editor_project_settings_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + add_test(NAME editor_ui_project_settings COMMAND faset_editor_project_settings_ui_tests) + set_tests_properties(editor_ui_project_settings PROPERTIES LABELS "gpu") + add_executable(faset_editor_reload_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_reload.cpp) + target_link_libraries(faset_editor_reload_ui_tests PRIVATE faset_editor_ui) + target_compile_definitions(faset_editor_reload_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + add_test(NAME editor_ui_reload COMMAND faset_editor_reload_ui_tests) + set_tests_properties(editor_ui_reload PROPERTIES LABELS "gpu") + add_executable(faset_project_launcher_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_launcher.cpp) + target_link_libraries(faset_project_launcher_tests PRIVATE faset_project_launcher) + target_compile_definitions(faset_project_launcher_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + add_test(NAME editor_ui_launcher COMMAND faset_project_launcher_tests) + set_tests_properties(editor_ui_launcher PROPERTIES LABELS "gpu") add_executable(faset_editor_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_tests.cpp) target_link_libraries(faset_editor_ui_tests PRIVATE faset_editor_ui) target_compile_definitions(faset_editor_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") @@ -9,6 +26,16 @@ if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view) add_dependencies(faset_editor_ui_tests faset_example_plugin) target_compile_definitions(faset_editor_ui_tests PRIVATE FASET_TEST_PLUGIN_DIRECTORY="${PROJECT_BINARY_DIR}/example-plugin") endif() + add_executable(faset_editor_template_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_templates.cpp) + target_link_libraries(faset_editor_template_ui_tests PRIVATE faset_editor_ui) + target_compile_definitions(faset_editor_template_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + add_test(NAME editor_ui_templates COMMAND faset_editor_template_ui_tests) + set_tests_properties(editor_ui_templates PROPERTIES LABELS "gpu") + add_executable(faset_editor_gizmo_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_gizmos.cpp) + target_link_libraries(faset_editor_gizmo_ui_tests PRIVATE faset_editor_ui) + target_compile_definitions(faset_editor_gizmo_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + add_test(NAME editor_ui_gizmos COMMAND faset_editor_gizmo_ui_tests) + 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") endif() diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index bdd2398..e1ac9f9 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -9,13 +9,13 @@ file(MAKE_DIRECTORY "${FASET_SHADER_DIRECTORY}") set(FASET_SHADER_OUTPUTS) foreach(FASET_ENTRY vertexMain fragmentMain shadowMain) set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.spv") - add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" - COMMAND "${SLANGC_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" - -entry "${FASET_ENTRY}" -target spirv -profile spirv_1_6 -matrix-layout-column-major - -o "${FASET_SHADER_OUTPUT}" -reflection-json "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json" - BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json" - DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" VERBATIM) - list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}") + add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json" + COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" + --compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" + --entry "${FASET_ENTRY}" --output "${FASET_SHADER_DIRECTORY}" + BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.slang-reflection.json" + DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM) + list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json") endforeach() add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv" COMMAND "${SLANGC_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" @@ -23,17 +23,27 @@ 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") +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") 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) +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_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp") - target_link_libraries(faset_render_tests PRIVATE faset_render) + target_link_libraries(faset_render_tests PRIVATE faset_render SDL3::SDL3) add_test(NAME render_graph COMMAND faset_render_tests --unit) add_test(NAME render_offscreen COMMAND faset_render_tests --gpu "${CMAKE_BINARY_DIR}/render-test.ppm") set_tests_properties(render_offscreen PROPERTIES LABELS "gpu") + add_executable(faset_render_reload_tests "${PROJECT_SOURCE_DIR}/tests/render_reload_tests.cpp") + target_link_libraries(faset_render_reload_tests PRIVATE faset_render faset_core) + target_compile_definitions(faset_render_reload_tests PRIVATE + FASET_TEST_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}" + FASET_TEST_SHADER_SOURCE="${PROJECT_SOURCE_DIR}/shaders/baseline.slang" + FASET_SHADER_COMPILE_TOOL="${PROJECT_SOURCE_DIR}/tools/compile_shader.py" + FASET_TEST_SLANGC="${SLANGC_EXECUTABLE}" + FASET_PYTHON_EXECUTABLE="${Python3_EXECUTABLE}") + add_test(NAME render_shader_reload COMMAND faset_render_reload_tests) + set_tests_properties(render_shader_reload PROPERTIES LABELS "gpu") endif() install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders) diff --git a/cmake/Tutorials.cmake b/cmake/Tutorials.cmake index 64e4733..08be144 100644 --- a/cmake/Tutorials.cmake +++ b/cmake/Tutorials.cmake @@ -12,3 +12,18 @@ if(BUILD_TESTING AND TARGET faset_runtime) add_test(NAME tutorial_${tutorial} COMMAND faset_tutorial_${tutorial}_tests) endforeach() endif() + +if(BUILD_TESTING AND TARGET faset_runtime) + foreach(dimension 2 3) + set(project_dir "${PROJECT_SOURCE_DIR}/examples/projects/collect-${dimension}d") + add_library(faset_example_${dimension}d STATIC "${project_dir}/Scripts/Gameplay.cpp") + target_include_directories(faset_example_${dimension}d PUBLIC "${project_dir}/Scripts") + target_link_libraries(faset_example_${dimension}d PUBLIC faset_runtime) + add_executable(faset_example_${dimension}d_tests "${PROJECT_SOURCE_DIR}/tests/runtime_tutorials_projects.cpp") + target_link_libraries(faset_example_${dimension}d_tests PRIVATE faset_example_${dimension}d) + target_compile_definitions(faset_example_${dimension}d_tests PRIVATE + FASET_EXAMPLE_DIMENSION=${dimension} + FASET_EXAMPLE_PROJECT="${project_dir}") + add_test(NAME playable_${dimension}d COMMAND faset_example_${dimension}d_tests) + endforeach() +endif() diff --git a/design/references/project-launcher-prototype.md b/design/references/project-launcher-prototype.md new file mode 100644 index 0000000..d5603da --- /dev/null +++ b/design/references/project-launcher-prototype.md @@ -0,0 +1,23 @@ +# Project launcher visual reference + +Generated with the built-in image generation tool before launcher implementation. +The original editor prototype supplied the style reference. + +This bitmap is **visual direction only**, not a source of functional requirements. +Architecture, PLAN.md, and verified workflows determine behavior. Project entries +shown in the image are illustrative; the application must display actual history. +Do not reproduce invented projects, gradients, or nonfunctional controls. + +Direction: near-black charcoal surfaces, compact native utility layout, thin +separators, readable gray text, restrained violet selection and primary action. +Adapt spacing and controls to keyboard navigation, DPI, validation, and actual +project opening/creation requirements. + +## Generation prompt + ++ Use case: ui-mockup. + Asset type: visual direction reference for Faset Engine's native desktop project launcher, not a functional specification. + Input image 1: style reference only, keep its quiet near-black charcoal palette, thin separators, small readable pale-gray typography and restrained violet selection accent. Create a NEW screen, do not edit the original editor. + Primary request: a polished, credible desktop game-engine project launcher. Straight-on screenshot, approximately 1100 by 720 canvas. Compact native title bar "Faset Engine". Narrow left column with modest wordmark "Faset", navigation "Open project" and "Create project", plus a short recent-project list. Main form in Create project mode with heading "Create project", name field "My Game", location "/projects/MyGame", small adjacent browse button, a simple 2D / 3D segmented choice with 3D selected, helper "You can use 2D and 3D in the same project.", and one compact "Create project" action. Recent projects may show "Collect & Escape 2D" and "Collect & Escape 3D" as illustrative entries. + Style: high-fidelity practical native desktop utility, mostly flat charcoal black like Obsidian/Notion, calm density and aligned form fields, generously readable but not oversized. Purposeful empty space below form. Crisp 1px dark separators. Plain text labels, subtle small corner radii. + Avoid: marketing hero, gradients, giant type, pill badges, dashboard cards, glows, stock art, decorative floating elements, fake metrics. No browser chrome. No additional features beyond the launcher. This image informs visual style only; implementation follows actual functional requirements. diff --git a/design/references/project-launcher-prototype.png b/design/references/project-launcher-prototype.png new file mode 100644 index 0000000..76c6b32 Binary files /dev/null and b/design/references/project-launcher-prototype.png differ diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 444e3dc..f022303 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -85,3 +85,59 @@ triangle glTF meshes/UV0, basic PBR/directional shadows and a conservative seria Vulkan renderer. Advanced rendering and broader content profiles remain later work. The generated UI reference determines visual direction only; architecture, behavior and acceptance criteria remain authoritative. + +## Checkpoint 3 — playable projects and complete authoring workflows + +Implemented and exercised on Linux: + +- Two playable C++ projects with real Box2D/Box3D input routes, pickups, a physical + gate, an exit condition and reset. The 3D project includes a reproducible original + Blender arch, `.blend` source, stable-ID bundle and import instructions. +- Native project launcher, retained folder browser, real recent projects, keyboard + navigation and safe project switching. GUI/MCP sessions retain one fixed project. +- Nested template UI, source navigation, local additions/suppression/reparent, + per-field origin/Revert and conflict preservation. Parented gizmos have tested + transforms, cancellation and one committed Undo operation. +- Explicit Project settings with file-content revisions and atomic save; per-scene + Simulation remains a separate authoring transaction. Live theme/layout reload + validates candidates and keeps working state on malformed edits. +- Standalone PNG/JPEG assets; versioned material records and explicit portable + cache profile/toolchain identity. Clearing cache preserves identity and overrides + when original sources and sidecars are reimported. +- Normalized Slang reflection, shader artifact hashes and renderer ABI compatibility + validation. Real compile failure, changed binding/matrix layout and invalid SPIR-V + keep a working pipeline; compatible pixel-changing reload succeeds. +- Physics debug box outlines, bounded raw Player profiles, observed validation + activation, CPU/GPU/readback durations and allocation counters. +- MCP broken-pipe/EOF handling and fresh GUI capture after presentation back-pressure. + A real GUI + stdio regression performs 12 PNG captures, a conflicting edit and Undo. +- English manual guides for workspace, templates, Blender, export and profiling, + alongside compiled C++ tutorials. Generated UI references remain visual guidance. + +Acceptance evidence: + +- The integrated Linux test suite passes **29/29 tests**, including ten GPU tests: + native CPU, Vulkan, UI, real MCP transport, plugins, schema, physics and playable + input routes. Strict MkDocs also passes. +- `tools/verify_playable_exports.py` exported the exact 2D/3D samples in Release, + imported the Blender arch, checked package hashes and relocated each package outside + its project. With the source-project paths unavailable, both passed validation and + 120 offscreen frames on RTX 2080 Ti with the Khronos layer active and zero errors. +- Renderer/shader regressions also passed the pinned Linux SwiftShader driver. This + is additional software-driver coverage, separate from Windows execution. +- `tools/measure_workflows.py` recorded a fresh sample Debug build and incremental + iteration, including stale-schema transitions. On the development host, the + initial build took 93.06 s, unchanged build 5.59 s, changed gameplay build 11.18 s, + and the subsequent one-frame Player process 0.37 s. The first/cached small Blender + arch imports each took about 0.047 s including Editor startup. Ambient builds were + running; these are observations, not release budgets. +- Profiling identified uncached readback memory as a concrete bottleneck. In a small + paired five-frame diagnostic, preferring compatible HOST_CACHED memory reduced + median readback from 27.87 to 0.47 ms and renderer-call wall time from 30.81 to + 2.92 ms. GPU work was about 0.58 ms. A longer final baseline is still required. + +This remains an implementation checkpoint. Windows full graphics/export CI is still +building its pinned software driver. A review also identified Windows Unicode path +boundaries that must be corrected before cross-platform acceptance. Clean offline +build verification, final performance baselines and the final acceptance record +remain open; no MVP tag has been created. diff --git a/docs/TOOLCHAINS.md b/docs/TOOLCHAINS.md new file mode 100644 index 0000000..87490c4 --- /dev/null +++ b/docs/TOOLCHAINS.md @@ -0,0 +1,71 @@ +# Toolchain and execution profiles + +Faset uses C++20 without compiler extensions. Dependencies are pinned by commit and +archive SHA-256 in `dependencies.lock.json`; Slang is pinned to **2026.18** by +`tools/fetch_slang.py`. Changing these inputs is a deliberate SDK change. Editor +plugins additionally require the exact generated build fingerprint, including +compiler, platform, CRT, configuration, dependencies and SDK source identity. + +## Observed compiler profiles + +- **Linux development:** Ubuntu 26.04, x86-64, Clang **21.1.8**, CMake **4.2.3**, + Ninja, glibc **2.43**. Native Editor, GPU tests, gameplay and exports execute here. +- **Linux CI:** Ubuntu 24.04, x86-64, Clang **18.1.3**. The headless CPU suite and + manual run on this profile. It is not evidence for desktop rendering on that runner. +- **Windows CI:** Windows Server 2025 runner, x86-64, clang-cl **20.1.8**, + MSVC toolset **14.51.36231**, Windows SDK **10.0.26100.0**. Headless tests have + passed. Full Editor, software Vulkan and Release-package acceptance are tracked + separately in the implementation log until that job completes. + +These are recorded validation profiles, not a claim that every intermediate Clang +release or every supported Windows desktop has been tested. The presets deliberately +use the compiler available on PATH so local installations remain practical. CI +configuration output records the actual detected versions; runner image upgrades +must be reviewed against this baseline. CMake **3.25** is the declared minimum, not +the exact version used in every recorded run. + +MSVC-family builds use the dynamic CRT: `/MDd` in Debug, `/MD` otherwise. Physics, +Player, Editor and native plugins use compatible settings. Development and export +have separate CMake directories, avoiding accidental Debug/Release mixing. + +## Graphics profiles + +The baseline requires Vulkan 1.3 with dynamic rendering and synchronization2, a +graphics queue, the renderer's color/depth format usages and limits, and presentation +support for a native window. The backend checks these requirements before creating +resources. It does not require hardware ray tracing, mesh shaders or descriptor +indexing. Timestamp availability is queried; unavailable GPU timings are reported +as unavailable rather than zero. + +- **Physical Linux GPU:** NVIDIA GeForce RTX 2080 Ti, driver **595.84**, reported + Vulkan **1.4.329**. Tests request `VK_LAYER_KHRONOS_validation` and report whether + the layer actually activated. This machine runs both Wayland window and offscreen + scenarios. +- **Software Vulkan:** official SwiftShader and Vulkan Loader sources are pinned + with archive hashes in `tools/ci/prepare_windows_vulkan.py`. This profile checks + API execution and output pixels on Windows CI. It is CPU rendering and is not a + physical GPU performance benchmark. The bootstrap does **not** install validation + layers, so its zero error counter must not be described as validation-layer proof. + +The measured physical GPU is one verified device, not an exhaustive compatibility +matrix. Additional AMD/Intel GPUs, Windows desktop drivers and display configurations +need their own execution records. + +## Offline preparation + +Before disconnecting, install the native toolchain and platform development packages, +fetch Slang, and run: + +```sh +python3 tools/fetch_dependencies.py +python3 tools/fetch_dependencies.py --verify-only +``` + +Preserve `.cache/downloads`, `.cache/slang`, compilers, CMake/Ninja, Python and system +SDK/libraries. A fresh source checkout can then configure and build without fetching +third-party sources. Runtime exports require only their documented target runtime +and graphics driver; they do not need this development toolchain. + +An offline build is recorded only after a clean build directory is configured and +built with network access disabled. A warm incremental build or archive checksum +verification alone is not that acceptance check. diff --git a/docs/manual/editor/assets.md b/docs/manual/editor/assets.md new file mode 100644 index 0000000..91d6b55 --- /dev/null +++ b/docs/manual/editor/assets.md @@ -0,0 +1,120 @@ +# Assets and Blender + +Keep source assets under your project's `Assets` directory. Faset imports PNG/JPEG, +static glTF/GLB, and manifests published by the optional Blender helper. The import +service runs in the Editor; exported games load cooked data and need no Blender. + +## Import a model + +1. Copy a `.glb`, or a `.gltf` with its relative buffers and images, into `Assets`. +2. In the **Assets** panel select the source and use **Import**. Watch the job state + and diagnostics. Import is asynchronous; the previous working generation stays + available until a complete replacement is ready. +3. Place the imported scene in the viewport. The Faset object stores the AssetId; + its transform, gameplay components, and physics remain authoring data. +4. Add a rigid body explicitly if needed. Import does not infer gameplay collisions + from the visual mesh. + +The same operation works through the command line: + +```sh +build/linux-debug/faset_editor --project MyGame --command \ + '{"name":"faset_import","arguments":{"path":"Assets/door.glb"}}' --wait +``` + +With MCP, call `faset_import`, then query `faset_job` using the returned job ID. +`faset_job_cancel` requests cancellation. Cancelling an import does not Undo an +authoring edit. See [MCP and CLI](mcp.md) for transport setup and errors. + +## Import an image + +PNG and JPEG become image assets. Drag an imported image into a scene to create a +sprite. `pixels_per_unit` is a positive import setting, defaulting to 100; it controls +the sprite's natural size. Color, transform, and layer remain scene properties. + +```json +{"name":"faset_import","arguments":{ + "path":"Assets/player.png","settings":{"pixels_per_unit":32} +}} +``` + +Omitting `settings` reuses the saved recipe. Providing an object replaces the recipe; +it is not a partial merge. Malformed or oversized images produce diagnostics and +retain the last successful generation. + +## Use the optional Blender helper + +The helper works in unmodified Blender. Blender 4.5.3 LTS is the version used for +the observed round-trip test. Ordinary GLB import remains available without it. + +1. Zip the repository's `tools/blender_addon` directory with that directory at the + ZIP's top level. Install the ZIP through Blender's add-on preferences and enable + **Faset GLB Export**. +2. Build a static scene using ordinary meshes and glTF-compatible PBR materials. +3. Choose **File → Export → Faset GLB Bundle** and place `manifest.json` in a directory + inside the Faset project's `Assets` directory. +4. **Save the `.blend` after the first export.** The helper assigns `faset_id` custom + properties to objects, meshes and materials, and `faset_asset_id` to the scene. +5. Import the exported `manifest.json` in Faset. Keep the manifest and its + `payload/.glb` files together. + +The helper publishes the manifest only after the payload is complete. It exports +the active Blender scene, not just the current selection. Gameplay and physics +components are added in Faset and are never written by the Blender helper. + +## Rename, duplicate, and reimport + +Renaming an object with a saved `faset_id` preserves its source identity. Re-export +and reimport update the shared asset; scene placements using that AssetId load the +new geometry while keeping their own transforms and components. + +Duplicating an object can also duplicate its custom ID. The exporter rejects +ambiguous IDs. Select the new copy and run **Faset: New IDs for Selected**. Shared +mesh datablocks remain shared. Repair deliberately duplicated material IDs in +Blender's Custom Properties. Save the `.blend` again. + +Removing an exported output produces an import conflict. Faset keeps the old +generation active. Inspect the diagnostics and update affected references before +explicitly importing with `allow_removed_outputs: true`. This accepts removal; +it does not automatically remap references. A normal GLB without persistent custom +IDs uses structural matching, which cannot guarantee identity after rename or +restructuring. + +## Coordinates and supported content + +The static profile uses right-handed glTF coordinates: metres, Y up. The Blender +helper enables glTF's Y-up conversion; do not add a second manual axis conversion. +Transforms and hierarchy are retained. Physics currently supports explicit boxes +on root-level objects. A visual imported mesh is not a mesh collider. + +The renderer uses static triangle meshes, UV0, base-color factor/texture, metallic +and roughness factors, and directional light/shadows. Import can retain additional +glTF material metadata, but normal/occlusion/emissive/metallic-roughness texture maps, +glTF alpha-mode selection, unlit selection and per-material face-culling behavior +are not all implemented in the baseline renderer. Diagnostics identify unsupported +material features. Procedural Blender node networks need baking or simplification. +Skinning, animation playback, morph targets and compressed geometry are outside this +profile. Imported material records use the versioned `faset.material` format inside +the asset manifest; a standalone material editor is later work. + +## What to commit and how to rebuild caches + +Commit your source files, Blender bundle manifests/payloads, and +`.faset-import.json` sidecars. Sidecars preserve AssetId and import settings. +Also commit `.faset-overrides.json` if used. Commit `.blend` sources when they +are part of your project. Do not commit `.faset/cache`. + +After clearing the cache or cloning a project, import the same sources again. Their +sidecars restore identities and recipes. Rebuild C++ to restore gameplay metadata. +Deleting the **entire** `.faset` directory also removes recovery journals and local +editor state; preserve unsaved work before doing that. An export fails if a referenced +asset has not yet been rebuilt. + +The cache key includes source/dependency hashes, settings, importer recipe, pinned +importer dependencies and the desktop target profile. Linux and Windows share this +portable content profile; runtime executable builds remain platform-specific. + +For an end-to-end example, open `examples/projects/collect-3d`. Its exit arch includes +a `.blend`, reproducible Blender script and a published bundle. The repository's +`tools/verify_blender_roundtrip.py` runs the actual Blender helper and Editor imports +to check rename, geometry changes, removal conflicts and failure preservation. diff --git a/docs/manual/editor/export.md b/docs/manual/editor/export.md new file mode 100644 index 0000000..b4904f5 --- /dev/null +++ b/docs/manual/editor/export.md @@ -0,0 +1,99 @@ +# Build, Play, and export + +Faset compiles C++ gameplay into a separate native Player. Changing C++ requires a +build and restart. Changes to an authoring scene can be tested without saving it: +**Play** captures the current resolved scene, builds gameplay, and opens its own +Player process. **Stop** leaves the authoring document unchanged. + +## The iteration loop + +1. Edit `Scripts/Gameplay.cpp` and its explicit schema declarations. +2. Stop the running Player. +3. Choose **Build C++**. The compiler and SchemaExporter run outside the Editor. +4. Read compiler errors in the Console. A failed build retains the previous schema + and binary; the schema status reports that it is stale. +5. After a successful build, edit the behavior's exposed fields in the Inspector. +6. Choose **Play**. It builds if necessary and launches the captured scene. + +Play captures the authoring document when requested. Editing the document while its +build runs does not silently change that snapshot. Runtime movement, spawned objects, +and gameplay progress do not write back into authoring or its Undo history. + +The Player supports pause and single-step. Editor controls use a private session +control file; they do not expose runtime entity queries through MCP. Closing the +Player is observed by the Editor, which keeps its logs and authoring state. + +## Export a standalone game + +Use **Export** in the Editor for the current scene. Resolve template conflicts and +import all referenced assets first. Export validates the scene, builds C++ and +metadata, cooks resources, copies runtime dependencies/notices, and verifies the +package before publishing it. + +Through MCP or the command interface, the operation is: + +```json +{"name":"faset_export","arguments":{ + "document":"the-open-document-id","output":"Exports/MyGame" +}} +``` + +The result is a job ID. Poll `faset_job` until `succeeded`, `failed` or `cancelled`. +The successful job's `result` identifies the package. Output paths are relative to +the project; the Editor rejects paths escaping it. + +Development builds use **Debug**. Exports default to **Release** and use a separate +CMake cache. The BuildService API also accepts `RelWithDebInfo` for exports. Exporting +does not change the configuration of your development Player. + +Each successful export creates an immutable directory under +`Exports/MyGame/generations/`. `current.json` points to the active +generation. Distribute the **whole generation directory**, not just the executable. +If building, cooking or verification fails, the previous pointer and package remain +available. Cancelling a job does not delete earlier exports. + +## Run the package + +Open the generation directory and launch `faset_player` on Linux or +`faset_player.exe` on Windows. The package selects its cooked start scene and shader +directory. It does not need the Faset source checkout, Editor, MCP, Blender, CMake, +SchemaExporter or Slang compiler. Vulkan drivers still create native GPU pipelines +from the packaged SPIR-V. + +The package includes its resource hashes, build profile and third-party notices. +Keep these files when redistributing it. Faset's own repository license remains an +explicit project-owner decision; third-party notices do not assign an engine license. + +## Platform requirements + +- **Linux x86-64:** a desktop session and Vulkan loader/driver exposing the baseline + Vulkan 1.3 features. Build on a distribution compatible with the target machines' + C/C++ runtime; the export is not an all-distribution static executable. +- **Windows x86-64:** a supported Vulkan driver and the compatible Microsoft Visual + C++ runtime. Release packages use the dynamic release CRT; install the matching + x64 Visual C++ Redistributable on the target machine. Debug development builds also + require development runtime libraries and are not the distribution package. + +Build and test Linux packages on Linux, Windows packages on Windows. Cross-compilation +is not part of this MVP. CI uses software Vulkan on Windows for deterministic image +and package execution tests; that is separate from physical GPU-driver testing. + +## Troubleshooting + +**Unknown component or schema version:** enable/register the missing runtime module +and rebuild. The Editor preserves its data as opaque authoring fields, but export +requires a matching runtime implementation. + +**Missing asset:** import the original source or Blender manifest again. Preserve its +sidecar so the AssetId remains stable. A cache copied from another project is not a +substitute for the correct source identity. + +**Shader build error:** fix the Slang diagnostic and build again. Failed compilation +retains the last successful shader generation; it is not reported as a successful +new build. A pipeline reload also checks resource layouts before replacing a working +pipeline. + +**No Vulkan device / unsupported feature:** use a compatible driver/device. The +renderer reports the required capability rather than silently selecting a reduced +graphics profile. Headless authoring and schema export do not require a GPU; playing +and capturing images do. diff --git a/docs/manual/editor/profiling.md b/docs/manual/editor/profiling.md new file mode 100644 index 0000000..915fa3e --- /dev/null +++ b/docs/manual/editor/profiling.md @@ -0,0 +1,76 @@ +# Profiling and measurements + +Use a Release export to measure the shipping Player. Record the exact scene, hardware, +driver, build configuration and resolution with the result. Small test scenes do not +establish performance for a large game. + +## Capture a bounded Player profile + +From a standalone generation directory: + +```sh +./faset_player --headless --frames 240 --profile profile.json +``` + +`--headless` here means **offscreen Vulkan rendering**. A GPU/driver is still required. +The Editor's headless authoring mode is a separate feature. Omit this flag to measure +the windowed path. `--profile` requires an explicit `--frames` between 1 and 100000, +which bounds the stored samples. + +The JSON contains raw completed-frame samples and nearest-rank p50/p95 summaries. +No warm-up frames are silently removed. It records the presentation mode, device, +resolution, validation activation, fixed ticks and timestep. A bounded run advances +one synthetic fixed timestep per frame; it does not reproduce a real-time input +session. Keep that distinction when comparing runs. + +Startup starts at `main()` and ends at the first completed frame. OS process loading +before `main()` is excluded. Frame wall times exclude writing the final profile and +capture files. Simulation and scene-snapshot times are separate from the renderer +call. Renderer CPU wall duration includes GPU waits and readback; it is **not CPU +utilization**. GPU timestamps measure the submitted graphics work and can be null +when timestamps are unsupported. + +Resource counters report live renderer allocations and texture count. GPU allocation +bytes include Vulkan allocation alignment and exclude driver-internal memory; they +are not a whole-process VRAM meter. The fallback white texture is included. + +Use `--debug-physics` or press **F3** to show current physics box colliders. Debug +geometry increases draw count, so record whether it was enabled. The collider view +uses simulation poses; normal visuals can use interpolated poses. + +## Measure Editor and C++ workflows + +From the engine repository: + +```sh +python3 tools/measure_workflows.py \ + --editor build/linux-debug/faset_editor \ + --project examples/projects/collect-3d \ + --output .cache/my-workflow-measurement +``` + +Use a new output directory. The tool copies the project, preserving your original, +and records command startup, two-frame GUI startup/shutdown, first/cached Blender +bundle import, initial/no-change/changed Debug builds and a subsequent Player frame. +It checks that editing gameplay makes the schema stale and successful building +clears that state. The initial build uses available dependency archives and OS +caches; it is not a measurement of internet download speed. + +On Linux, GNU `time` records peak RSS for each command and its waited-for children. +This is a maximum, not the sum of simultaneous compiler processes. Other platforms +report this field as null unless equivalent measurement support is added. The tool +keeps raw stdout/stderr, durations, hardware and revision information alongside its +report. A dirty source checkout is explicitly identified. + +`tools/verify_playable_exports.py` separately verifies the two sample games in +relocated Release packages and records their Player profiles. Its assertions test +correct execution, not a frame-time threshold. + +## Current performance scope + +The MVP renderer is intentionally conservative: direct draws, CPU culling, one +graphics queue and synchronous capture/readback. Use the measurements to find the +next bottleneck before introducing parallel jobs or GPU-driven rendering. Neither +an offscreen capture benchmark nor a tiny demo is a promise of a production frame +budget. Observed measurements and follow-up targets belong in the implementation +acceptance report with their source revision and method. diff --git a/docs/manual/editor/templates.md b/docs/manual/editor/templates.md new file mode 100644 index 0000000..853c9e6 --- /dev/null +++ b/docs/manual/editor/templates.md @@ -0,0 +1,128 @@ +# Scene templates and local overrides + +A template is an ordinary `.scene.json` scene used as the source of an instance in +another scene. Each instance stores its source path and local differences. Editing +a source field updates instances that have not overridden that field. Templates can +contain other scene instances, forming a nested composition. + +## Make a reusable object + +1. Create an object and its children, then select the root object in the Scene tree. +2. Open **Scene** and set the template path, for example + `Assets/Templates/Door.scene.json`. +3. Choose **Save selection as template**. +4. Select a containing scene, open **Scene**, enter the same source path, and choose + **Instance scene at this path**. Alternatively, select the saved scene in Assets + and choose **Instance Scene**. + +The save operation copies the selected **resolved subtree** to a new source file. +It does not replace the original objects with an instance or apply changes to an +existing source. Nested content inside that copied subtree is flattened into ordinary +objects. The selected root becomes parentless and retains its local transform, so +check its placement if it was previously under a transformed parent. References to +objects outside the copied subtree need deliberate handling; they are not collected +automatically. + +Use a new path: Save As will not overwrite another existing scene file. Creating this +source file is separate from the containing scene's Undo history. Remove the original +objects only if you intend to replace them with your new instance. + +## Recognize an instance + +The Scene tree shows a **[T]** group named after the source scene. Nested instances +have their own indented groups. Select the group to see **Scene instance** in the +Inspector, including its source path and **Open source**. + +Select an inherited object inside the group to edit its fields. Its Inspector shows +the source filename. Each exposed field is marked **Source** when inherited or +**Override** when the containing scene supplies a local value. + +To change one door's position or behavior setting, edit that field in the instance. +This records a local override; it does not modify the source file. **Revert** removes +that field's local override so it follows the source again. Undo can restore the +previous override. + +## Edit the source + +Choose **Open source** from an instance or inherited-object Inspector. Edit the +opened source scene, then use the viewport's **Back** button to return to the previous +document. Back switches documents; it is not an Undo operation. + +The current editor session resolves instances against an open source document's +in-memory state, including unsaved edits. Save each changed source scene to make those +edits available after restarting or to another session. Saving only the containing +scene does not save its source documents. + +Renaming, adding/removing source components, and deeper nested structural changes are +done in the appropriate source document. Inherited object names and component +structure are not editable as arbitrary local overrides. There is no **Apply all +instance changes to source** action in this version: open the source and make the +intended shared edit explicitly. + +## Make structural changes to one instance + +The top-level instance Inspector provides these operations: + +- **Add local object** creates an object owned by this instance. An inherited object's + **Add local child** creates one parented to that object. Local additions can be + renamed and have components added or removed without changing the source. +- **Delete** on an inherited object suppresses it in this instance. The source stays + intact. Select the instance group and choose its **Restore suppressed object …** + button to remove that suppression. +- **Remove instance** removes the entire top-level instance record from the containing + scene. It does not delete the source file. Undo restores the record and its local + differences. +- Edit the top-level instance's **source path** to point to another scene. Existing + local differences are retained; review Conflicts because the new source may not + contain their targets. + +These edits are authoring transactions and support Undo. Removing a local addition +from the visible instance also uses suppression, so the instance Inspector can restore +it. + +Drag an inherited object's tree row onto another object **within the same instance +path** to reparent it locally. The editor keeps its world pose when representable. +Dragging across instance boundaries is rejected; use **Add local child** or edit the +source hierarchy instead. A nested instance group disables top-level structural +controls and directs you to **Open source**. + +## Resolve conflicts without losing overrides + +An override targets source object, component, and field IDs rather than the label +shown in the tree. If the source component is removed, for example, the local override +cannot be applied. The Editor keeps that record and lists it in **Conflicts** instead +of silently discarding it. + +For a reproducible example: + +1. Add a component to the source and save it. +2. In the containing scene, override one of that component's fields. +3. Open the source and remove that component. +4. Return with **Back**. The containing scene reports the unresolved override. + +Use the conflict's **Open source** button to inspect the change. If the source deletion +was accidental, undo it in the source document; restoring the original identity lets +the override resolve again. Recreating a same-named component can give it a new ID and +does not automatically reconnect the old record. + +If the local value is no longer needed, **Discard override** removes that record from +the containing scene. This action supports Undo. A missing suppressed object can also +show **Discard suppression**. These discard buttons cover supported top-level records; +for nested-source conflicts, open the source that owns the change. Other conflict +kinds are displayed for diagnosis and are not automatically repaired by renaming. + +Resolve conflicts before Play or export. A missing source, source cycle, or invalid +address is not a successful partial game build. See [Build, Play, and export](export.md) +for validation and job diagnostics. + +## Scope and persistence + +Save the containing scene to persist its instance paths, overrides, suppressions, +local additions, and reparents. Save source scenes separately. Unsaved committed +changes have the same [recovery behavior](workspace.md#recover-unsaved-work) as other +authoring edits. + +Template resolution produces the flattened scene used for preview and Player startup. +The running game's components are independent of this authoring composition: gameplay +writes do not become template overrides, and MCP does not inspect or change the +Player's live entities. diff --git a/docs/manual/editor/workspace.md b/docs/manual/editor/workspace.md new file mode 100644 index 0000000..70ae41d --- /dev/null +++ b/docs/manual/editor/workspace.md @@ -0,0 +1,174 @@ +# Editor workspace + +The Editor edits saved scene data and previews it in a Vulkan viewport. **Play** +opens a separate Player process. Gameplay movement and spawned objects stay in that +process; stopping it leaves the authoring scene unchanged. + +## Open or create a project + +Launch `faset_editor` without arguments to open the project launcher. + +1. Choose **Create project**, enter a name and a new or empty project directory, + then select **2D** or **3D** for the initial scene. +2. Choose **Create project** to create the project files and open the Editor. +3. To return later, choose **Open project**, select its directory with **Browse**, + and confirm **Open project**. The directory must contain `project.faset.json`. + A recent-project entry fills the directory field; confirm to open it. + +The directory browser has **Up**, **Home**, and folder rows. Choose the directory +before confirming the launcher. **Tab** moves focus, **Ctrl+Enter** confirms the +current launcher/browser action, and **Escape** cancels the browser or launcher. + +In an open Editor, **File → Open / create another project** returns to the launcher. +If there are unsaved scenes, queued/running jobs, or a Player, a dialog offers +**Cancel** or **Switch project**. Cancel and save first if you want ordinary scene +files updated. Switching stops that session's jobs and Player; unsaved authoring +changes remain in recovery journals. Project switching is disabled when the Editor +was launched in MCP mode so the connection keeps a single project context. + +For a direct launch, use `faset_editor --project /path/to/MyGame` on Linux or +`faset_editor.exe --project C:\Projects\MyGame` on Windows. + +## Create and save a scene + +Use **File → New 2D scene** or **New 3D scene**. The Scene panel offers **+ Object**, +**Cube**, and **Sprite**. Select an object in the tree or viewport to inspect it. + +Choose **Save** or press **Ctrl+S**. A new scene opens the File menu's path field; +enter a project-relative path such as `Scenes/Room.scene.json`, then choose +**Save scene as**. Subsequent saves update that file. The title's `*` marks unsaved +changes. **Open project-relative scene** opens another scene by its project-relative +path; the Assets panel also has **Open Scene**. + +Save As refuses to overwrite a different existing file. If the scene file changed +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. + +## Navigate and transform + +- **Right drag:** orbit the 3D view; pan in a 2D scene. +- **Middle drag:** pan. **Mouse wheel:** zoom. +- **F** or **Frame:** frame the selected object. +- **W / E / R:** choose Move / Rotate / Scale. The viewport also has named buttons. +- Drag a gizmo axis to preview a transform; release to commit one Undo operation. + **Escape** cancels the drag. +- Hold **Ctrl** while dragging to snap: movement uses 0.25-unit increments, + rotation uses 15-degree increments, and scale uses 0.25 increments. +- **Delete** removes a local object, or suppresses an inherited template object. + **Edit → Duplicate selection** duplicates an ordinary local subtree. + +Move uses world axes, including for parented objects. Rotate and Scale operate in +local space. The Inspector stores rotation in **radians**. Dragging one Scene-tree +object onto another reparents it while preserving its world pose when representable; +drop into the tree's empty area to return it to the root. Template boundaries impose +additional rules described in [Scene templates](templates.md). Physics bodies must +remain roots for the current runtime adapters. + +These shortcuts apply when a text or numeric editor is not consuming the key. +The viewport camera is an editing camera; navigating it does not rewrite a scene +camera component. + +## Edit components and undo + +The Inspector uses the registered component schema to show numbers, vectors, +checkboxes, enum choices, and text. Use **+ Add Component** to attach a registered +type; the component header's **x** removes a locally owned component. + +Type into a field and press **Enter**, or move focus to commit. Numeric fields also +support dragging. A committed field edit or completed drag is one scene Undo action; +preview changes are not separate history entries. **Escape** cancels an unfinished +field edit. **Tab / Shift+Tab** move between controls. Invalid numbers keep their +error state until corrected or cancelled. + +Use **Undo / Redo** or **Ctrl+Z / Ctrl+Shift+Z** after finishing the active field. +While editing text, its own editing history can consume Undo. Scene history belongs +to the active document: returning from a source template and undoing in its containing +scene does not undo the source document's edits. + +GUI actions and MCP use the same authoring command service. If another action changes +the document while a field or gizmo drag is in progress, the old revision is rejected. +Read the fresh value and retry; the stale edit does not silently replace the newer one. + +## Refresh C++ metadata + +**Build C++ !** and the status message indicate stale gameplay metadata, for example +after changing gameplay sources or after a failed build. Choose **Build C++**, then +check **Jobs** and **Console**. A successful build and schema export refresh the +Inspector. A failed build retains the previous metadata and reports the failure. + +A missing schema or unsupported component version appears as read-only raw fields +with **Copy raw fields**. The Editor preserves that data. Restore the matching module +or provide a migration and rebuild before expecting normal field editing or Play. +See [Build, Play, and export](export.md) for the C++ iteration loop. + +## Project settings + +Open **File → Project settings** to change the project name, initial **2D / 3D** type, +and start-scene path. Save a scene first, then choose it from the saved-scene list or +enter its project-relative path. Choose **Save project** to update +`project.faset.json` explicitly. A missing start scene is rejected and the dialog +stays open. + +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 +saved settings before you retry. + +## Configure scene simulation + +Choose **Simulation** in the toolbar. These settings belong to the **current scene**: +fixed tick rate in Hz, maximum catch-up ticks, physics substeps, and gravity XYZ. +Defaults are 60 Hz, 4 catch-up ticks, 4 substeps, and `(0, -9.81, 0)` gravity in metres +per second squared. Each committed change is one Undo action and is saved with the +scene. + +The next **Play** snapshot uses the settings. Editing them does not reconfigure an +already running Player. The 2D adapter uses gravity X/Y and ignores Z. See +[Physics and grounded movement](../scripting/physics.md) before changing the tick rate +or substeps. + +## Panels and commands + +**Assets** lists project files and imported resources. **Console** shows recent +messages and diagnostics. **Jobs** shows build/import/export progress and cancellation. +**Conflicts** lists unresolved template records. Drag the bottom tabs to reorder them; +drag panel dividers to resize the Scene, Inspector, and bottom areas. These choices +are stored per project in `.faset/editor-layout.json`. + +The current layout supports these panel sizes and bottom-tab ordering. It does not +provide floating panels or multiple Editor windows. Theme and base layout JSON live +in the engine's `assets/ui/dark.json` and `assets/ui/editor-layout.json`. The Editor +checks their contents every 500 ms and applies valid changes without restarting. +Malformed changes retain the last working appearance and report an error in Console; +fix the files and the next successful check applies them. Focus and unfinished field +text are preserved. + +Layout reload updates properties of existing widgets; retain their IDs, kinds, and +parents. It is not a way to add arbitrary controls or move them to new parents. +Theme-only edits preserve resized panel widths. Editing the base layout can replace +properties explicitly present there, including panel sizes; per-project docking +preferences are stored separately. + +**Commands** or **Ctrl+P** opens the command palette. Filter by command name, select a +command, enter its JSON arguments, and choose **Run selected command**. Results appear +in Console. For example, `faset_schema_status` takes `{}`. This is the same editor +command surface described in [MCP and command line](mcp.md). + +## Recover unsaved work + +Committed authoring changes are written to `.faset/recovery/`. On startup, +**Unsaved authoring recovery** offers **Restore <scene name>** for dirty journals. +Restore brings the recovered scene into memory; inspect it and **Save** when ready. +It recovers scene data, not the full previous session's Undo history or Player state. + +**Continue without restoring** closes the prompt without deleting its journals. +Later edits or saves of the same document can replace its recovery record, so restore +or copy a journal before continuing if you still need that draft. Uncommitted text +or gizmo previews are not recovery checkpoints. + +If the scene file changed or disappeared since the journal was written, restoration +reports a disk conflict. Keep copies of the journal and current file, then reconcile +them explicitly; recovery does not automatically overwrite external changes. diff --git a/docs/manual/getting-started/build.md b/docs/manual/getting-started/build.md index 6144c98..db86d4e 100644 --- a/docs/manual/getting-started/build.md +++ b/docs/manual/getting-started/build.md @@ -36,6 +36,9 @@ Create a project and open the native Editor: 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 and create or select a project using the native interface. + Use **Build C++** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**. The Player runs separately. Stop it before changing and rebuilding C++ gameplay. See [MCP and CLI](../editor/mcp.md) for headless authoring and automation. @@ -73,3 +76,6 @@ ctest --preset windows-debug Windows acceptance is tracked separately from Linux; a successful Linux build does not verify a Windows build. + +The repository's `docs/TOOLCHAINS.md` records the exact compiler, SDK and GPU profiles +used in observed validation, separately from the minimum tool requirements above. diff --git a/docs/manual/index.md b/docs/manual/index.md index bc5ac4e..eed7a87 100644 --- a/docs/manual/index.md +++ b/docs/manual/index.md @@ -7,7 +7,8 @@ and how those functions interact with scenes, physics, and the editor. !!! warning "Development status" MVP implementation is in progress. A planned feature is not a working feature. Individual guides state their prerequisites and validation status. The current - Editor, gameplay tutorials and Linux export can be built and tested. Final Windows graphics/export acceptance and complete sample games are still in progress. + Editor, gameplay tutorials, two playable sample games and Linux export can be built + and tested. Final Windows graphics/export acceptance is still in progress. Start with [how C++ gameplay works](scripting/index.md), then read [frame and physics updates](scripting/lifecycle.md). See diff --git a/docs/manual/scripting/api.md b/docs/manual/scripting/api.md index e4603b7..62a65e0 100644 --- a/docs/manual/scripting/api.md +++ b/docs/manual/scripting/api.md @@ -38,7 +38,7 @@ These are values, not borrowed component pointers. Changing a returned copy has - `void teleport(EntityHandle, const Transform&)` changes the pose discontinuously, wakes the body, and resets interpolation/contact-query history. It does not zero velocity. - `bool grounded(EntityHandle) const` tests support using recent native contact normals. -These methods require a valid handle and a physics body. Ordinary `setTransform` is rejected for physical objects, including static and kinematic bodies. See [physics](physics.md) for dimensions, tolerances, and collider limits. +All methods require a valid handle. Velocity, impulse, and grounded queries also require a physics body; `teleport` accepts physical and non-physical objects. Ordinary `setTransform` is rejected for physical objects, including static and kinematic bodies. See [physics](physics.md) for dimensions, tolerances, and collider limits. ## Input and collision events @@ -68,3 +68,23 @@ Immediate validation errors throw. Deferred failures are recorded in diagnostics `RuntimeConfig` defaults to `fixedDelta = 1.0 / 60.0`, `maxCatchUpTicks = 4`, `physicsSubsteps = 4`, and `gravity = {0, -9.81f, 0}`. `FrameStats` reports fixed ticks performed, dropped time, interpolation fraction, and total tick count. `snapshot()` returns a value snapshot for rendering; `snapshotJson()` provides its JSON representation. `diagnostics()` returns a read-only vector of runtime messages. These are native C++ APIs for the Player and tests, **not MCP endpoints**. + +## Inspect a running Player locally + +The Player has local development controls in addition to gameplay input: **P** toggles +pause, **N** steps one fixed tick while paused, and **Escape** closes the Player. +**F3** toggles physics-box outlines; `--debug-physics` enables them from startup. +Static bodies are green, kinematic bodies orange, and dynamic bodies cyan. + +These outlines use current physics poses and collider half-extents multiplied by the +absolute transform scale. They can differ slightly from an interpolated visible mesh. +The initial adapters support root-only boxes: four outline edges in 2D, twelve in 3D. +This view does not show contact normals, broad-phase cells, or arbitrary mesh colliders. + +To record measurements, run the Player with `--profile measurements.json --frames 240`. +A profile requires an explicit count from 1 to 100,000. As with all bounded Player +runs, simulation uses the configured fixed delta each frame; `--headless` renders +through offscreen Vulkan. The report contains measured durations rather than treating +that simulation delta as frame time. See [Player profiling](../editor/profiling.md) +for startup, CPU/GPU timing, percentiles, and their limits. These flags do not add MCP +to the Player. diff --git a/docs/manual/scripting/examples.md b/docs/manual/scripting/examples.md index 69e275f..0d42a5b 100644 --- a/docs/manual/scripting/examples.md +++ b/docs/manual/scripting/examples.md @@ -48,3 +48,11 @@ ctest --test-dir build/linux-debug -R '^tutorial_' --output-on-failure On Windows use the chosen Windows build directory. The four tests compile separate gameplay libraries and execute their actual callbacks. They require neither a graphics window nor the Editor. Running a tutorial through `faset_player` additionally checks rendering and platform integration and requires the documented Vulkan setup. Schema declarations are tested for stable map-key/FieldId matching and defaults. They remain ordinary C++ source; the engine does not scan arbitrary C++ classes to create this API automatically. + +## Play complete small projects + +`examples/projects/collect-2d` and `examples/projects/collect-3d` contain complete projects with a manifest, scene, and a separate `Scripts` module. Open either folder with the Editor's `--project` option, then use Play. Each project's README also provides a direct Player build command. + +Move the blue block with A/D in 2D or WASD in 3D, jump with Space, collect three gold cubes, and reach the green exit after its red gate opens. E resets the round. One pickup is on a raised platform. A visible gold marker and the Player log confirm completion; these initial examples use geometric progress displays instead of a text HUD. + +The `playable_2d` and `playable_3d` CTests drive the actual modules through input, including the jump, objective, reset, and fresh session. The 3D module also explicitly registers a separately packaged `example.beacon` component from its local `Scripts/Extensions/Beacon.hpp`. Both projects use built-in geometry and need no imported assets to start. diff --git a/examples/projects/collect-2d/.gitignore b/examples/projects/collect-2d/.gitignore new file mode 100644 index 0000000..066358d --- /dev/null +++ b/examples/projects/collect-2d/.gitignore @@ -0,0 +1,2 @@ +.faset/ +Exports/ diff --git a/examples/projects/collect-2d/README.md b/examples/projects/collect-2d/README.md new file mode 100644 index 0000000..943d7f9 --- /dev/null +++ b/examples/projects/collect-2d/README.md @@ -0,0 +1,55 @@ +# Collect & Escape 2D + +A small playable project using the real Faset C++ gameplay module and Box2D adapter. All geometry is built in, so this project does not need an asset import or Blender installation. + +## Play + +Open it from the repository root with a built Editor: + +```bash +build/linux-debug/faset_editor --project examples/projects/collect-2d +``` + +Use **Play** to build this project's `Scripts/Gameplay.cpp` and start a separate Player. On Windows use your Windows build directory and `faset_editor.exe`. First complete the engine build setup in the User Manual. + +For a direct Player build on Linux, from the repository root: + +```bash +cmake -S . -B build/collect-2d -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DFASET_GAMEPLAY_SOURCE_DIR="$PWD/examples/projects/collect-2d/Scripts" +cmake --build build/collect-2d --target faset_player faset_schema_exporter +build/collect-2d/faset_player --scene examples/projects/collect-2d/Scenes/main.scene.json +``` + +On Windows use a Developer shell with `clang-cl` and the Windows SDK, select `clang-cl` for both compiler options, and give `FASET_GAMEPLAY_SOURCE_DIR` an absolute path. + +## Objective and controls + +- A/D or left/right arrows move on X. +- **Space** jumps while a native contact supports the player block. +- Collect all three **gold cubes**. Jump onto the raised platform for the middle pickup. +- Each pickup moves to the progress display. After all three, the **red physical gate** moves out of the level. +- Reach the **green exit** to reveal the large gold victory marker. Movement stops after completion. +- **E** restores the player, pickups, gate, and victory state. Falling below the level also resets the round. +- **P** pauses, **N** advances one fixed tick, and **Escape** closes the Player. + +The Player window title and console identify the controls and objective; the game uses geometric progress markers rather than a text HUD. The player is a dynamic box with zero friction, not an articulated character controller. It can rotate after impacts. + +## Inspect and change the game + +`project.faset.json` chooses `Scenes/main.scene.json`. The scene stores object/component IDs, transforms, physics settings, and entity references in the collector component. `Scripts/Gameplay.cpp` owns the round's transient C++ state and registers its editable schema. `speed` and `jump_speed` use metres per second. + +Change JSON and restart Play to see new level data. Change C++ or its schema, then stop, build, and restart; C++ hot reload is not implemented. Runtime pickup progress does not rewrite the authoring scene or create Undo entries. + +## Verify + +The normal test build creates `faset_example_2d_tests`. It compiles this exact module, follows a route using movement/jump input, collects the elevated pickup, opens the gate, reaches the exit, resets the round, and restarts the scene. It does not teleport to pass the objective. + +```bash +cmake --build build/linux-debug --target faset_example_2d_tests +ctest --test-dir build/linux-debug -R '^playable_2d$' --output-on-failure +``` + +The runtime test is independent of graphics. A Player run additionally requires the supported Vulkan platform setup. The Editor's Export action creates a separate Release build and a cooked standalone generation; run that generation's Player from its export directory. diff --git a/examples/projects/collect-2d/Scenes/main.scene.json b/examples/projects/collect-2d/Scenes/main.scene.json new file mode 100644 index 0000000..a51add9 --- /dev/null +++ b/examples/projects/collect-2d/Scenes/main.scene.json @@ -0,0 +1,659 @@ +{ + "format": "faset.scene", + "version": 1, + "id": "example-collect-2d-scene", + "name": "Collect & Escape 2D \u2014 WASD / Space / E reset", + "dimension": 2, + "simulation": { + "fixed_delta": 0.016666666666666666, + "max_catch_up_ticks": 4, + "physics_substeps": 4, + "gravity": [ + 0, + -9.81, + 0 + ] + }, + "entities": [ + { + "id": "floor", + "name": "Ground", + "parent": null, + "components": [ + { + "id": "floor-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + -0.75, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "floor-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ], + "size": [ + 21, + 0.5 + ], + "texture": "", + "layer": 0 + } + }, + { + "id": "floor-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 10.5, + 0.25 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "platform", + "name": "Jump platform", + "parent": null, + "components": [ + { + "id": "platform-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "platform-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.35, + 0.44, + 0.55, + 1 + ], + "size": [ + 2, + 1 + ], + "texture": "", + "layer": 0 + } + }, + { + "id": "platform-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 1.0, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "left-wall", + "name": "Left boundary", + "parent": null, + "components": [ + { + "id": "left-wall-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -10.25, + 1, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "left-wall-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ], + "size": [ + 0.5, + 3.5 + ], + "texture": "", + "layer": 0 + } + }, + { + "id": "left-wall-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.25, + 1.75 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "right-wall", + "name": "Right boundary", + "parent": null, + "components": [ + { + "id": "right-wall-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 10.25, + 1, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "right-wall-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ], + "size": [ + 0.5, + 3.5 + ], + "texture": "", + "layer": 0 + } + }, + { + "id": "right-wall-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.25, + 1.75 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "token_1", + "name": "Gold pickup 1", + "parent": null, + "components": [ + { + "id": "token_1-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -5, + 0.35, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "token_1-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 1, + 0.68, + 0.14, + 1 + ], + "size": [ + 0.5, + 0.5 + ], + "texture": "", + "layer": 0 + } + } + ] + }, + { + "id": "token_2", + "name": "Gold pickup 2", + "parent": null, + "components": [ + { + "id": "token_2-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 1.35, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "token_2-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 1, + 0.68, + 0.14, + 1 + ], + "size": [ + 0.5, + 0.5 + ], + "texture": "", + "layer": 0 + } + } + ] + }, + { + "id": "token_3", + "name": "Gold pickup 3", + "parent": null, + "components": [ + { + "id": "token_3-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 5, + 0.35, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "token_3-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 1, + 0.68, + 0.14, + 1 + ], + "size": [ + 0.5, + 0.5 + ], + "texture": "", + "layer": 0 + } + } + ] + }, + { + "id": "gate", + "name": "Collect all three to open", + "parent": null, + "components": [ + { + "id": "gate-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 7, + 1, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "gate-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.9, + 0.23, + 0.16, + 1 + ], + "size": [ + 0.45, + 3 + ], + "texture": "", + "layer": 0 + } + }, + { + "id": "gate-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.225, + 1.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "goal", + "name": "Green exit", + "parent": null, + "components": [ + { + "id": "goal-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 8.5, + 0.15, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "goal-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.15, + 0.9, + 0.47, + 1 + ], + "size": [ + 0.85, + 0.3 + ], + "texture": "", + "layer": 0 + } + } + ] + }, + { + "id": "win_marker", + "name": "Victory marker", + "parent": null, + "components": [ + { + "id": "win_marker-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 8.5, + 3.0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "win_marker-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 1, + 0.68, + 0.14, + 1 + ], + "size": [ + 1, + 1 + ], + "texture": "", + "layer": 0 + } + } + ] + }, + { + "id": "player", + "name": "Blue player block", + "parent": null, + "components": [ + { + "id": "player-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -8, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "player-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.12, + 0.65, + 1, + 1 + ], + "size": [ + 1, + 1 + ], + "texture": "", + "layer": 0 + } + }, + { + "id": "player-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "dynamic", + "half_extents": [ + 0.5, + 0.5 + ], + "density": 1, + "friction": 0, + "restitution": 0, + "gravity_scale": 1 + } + }, + { + "id": "player-example.collector_2d", + "type": "example.collector_2d", + "version": 1, + "fields": { + "speed": 4, + "jump_speed": 6, + "gate": "gate", + "goal": "goal", + "win_marker": "win_marker", + "token_1": "token_1", + "token_2": "token_2", + "token_3": "token_3" + } + } + ] + } + ], + "instances": [] +} diff --git a/examples/projects/collect-2d/Scripts/Gameplay.cpp b/examples/projects/collect-2d/Scripts/Gameplay.cpp new file mode 100644 index 0000000..6b5129c --- /dev/null +++ b/examples/projects/collect-2d/Scripts/Gameplay.cpp @@ -0,0 +1,157 @@ +#include "Gameplay.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace faset::gameplay { +namespace { +constexpr bool is3D = false; +constexpr const char* collectorType = "example.collector_2d"; +using Key = std::tuple; +Key key(runtime::EntityHandle handle) { + return {handle.session, handle.slot, handle.generation}; +} +struct Round { + runtime::Transform spawn, gatePose, victoryPose; + runtime::EntityHandle gate, goal, victory; + std::array tokens; + std::array tokenPoses; + std::array collected{}; + bool won{}; + bool gateOpened{}; +}; +float distance(runtime::Vec3 a, runtime::Vec3 b) { + const float x = a[0] - b[0], y = a[1] - b[1], z = is3D ? a[2] - b[2] : 0; + return std::sqrt(x * x + y * y + z * z); +} +void reset(runtime::Runtime& game, runtime::EntityHandle self, Round& round) { + game.teleport(self, round.spawn); + game.setVelocity(self, {0, 0, 0}); + game.teleport(round.gate, round.gatePose); + for (std::size_t i = 0; i < 3; ++i) + game.setTransform(round.tokens[i], round.tokenPoses[i]); + auto hidden = round.victoryPose; + hidden.position[1] = -50; + game.setTransform(round.victory, hidden); + round.collected = {}; + round.won = false; + round.gateOpened = false; + std::cout << "New round: collect the three gold cubes, then reach the green exit. E resets.\n"; +} +} // namespace + +void registerGameplay(runtime::Runtime& world) { + + auto rounds = std::make_shared>(); + runtime::Behavior collector; + collector.onStart = [rounds](runtime::Runtime& game, runtime::EntityHandle self, double) { + const auto fields = game.fields(self, collectorType); + Round round; + round.spawn = game.transform(self); + auto resolve = [&](const char* field) { + const auto handle = game.find(fields.at(field).get()); + if (!game.valid(handle)) + throw std::runtime_error(std::string("Missing level reference: ") + field); + return handle; + }; + round.gate = resolve("gate"); + round.goal = resolve("goal"); + round.victory = resolve("win_marker"); + round.gatePose = game.transform(round.gate); + round.victoryPose = game.transform(round.victory); + for (std::size_t i = 0; i < 3; ++i) { + const auto field = "token_" + std::to_string(i + 1); + round.tokens[i] = resolve(field.c_str()); + round.tokenPoses[i] = game.transform(round.tokens[i]); + } + auto& stored = rounds->insert_or_assign(key(self), std::move(round)).first->second; + reset(game, self, stored); + }; + collector.onDestroy = [rounds](runtime::Runtime&, runtime::EntityHandle self, double) { + rounds->erase(key(self)); + }; + collector.fixedUpdate = [rounds](runtime::Runtime& game, runtime::EntityHandle self, double) { + auto& round = rounds->at(key(self)); + const auto input = game.input(); + const auto position = game.transform(self).position; + if (input.interactPressed || position[1] < -8) { + reset(game, self, round); + return; + } + const auto fields = game.fields(self, collectorType); + auto velocity = game.velocity(self); + float x = input.horizontal, z = is3D ? -input.vertical : 0; + const float length = std::sqrt(x * x + z * z); + if (length > 1) { + x /= length; + z /= length; + } + const auto speed = fields.value("speed", 4.0f); + velocity[0] = round.won ? 0 : x * speed; + if (is3D) + velocity[2] = round.won ? 0 : z * speed; + if (!round.won && input.jumpPressed && game.grounded(self)) + velocity[1] = fields.value("jump_speed", 6.0f); + game.setVelocity(self, velocity); + if (round.won) + return; + for (std::size_t i = 0; i < 3; ++i) + if (!round.collected[i] && distance(position, round.tokenPoses[i].position) < 0.95f) { + round.collected[i] = true; + auto display = round.tokenPoses[i]; + display.position = is3D ? runtime::Vec3{6, 2 + float(i) * 0.8f, -4} + : runtime::Vec3{-1.2f + float(i) * 1.2f, 4.5f, 0}; + game.setTransform(round.tokens[i], display); + std::cout << "Collected " + << std::count(round.collected.begin(), round.collected.end(), true) + << "/3\n"; + } + if (std::all_of(round.collected.begin(), round.collected.end(), + [](bool value) { return value; })) { + if (!round.gateOpened) { + auto open = round.gatePose; + open.position[1] = -30; + game.teleport(round.gate, open); + round.gateOpened = true; + } + if (distance(position, game.transform(round.goal).position) < 1.0f) { + round.won = true; + game.setTransform(round.victory, round.victoryPose); + std::cout << "Level complete! The gold victory marker is visible. Press E to play " + "again.\n"; + } + } + }; + world.registerBehavior(collectorType, std::move(collector)); +} + +nlohmann::json schema() { + nlohmann::json fields = {{"speed", + {{"id", "speed"}, + {"name", "Move speed"}, + {"type", "number"}, + {"default", 4.0}, + {"min", 0.0}, + {"units", "m/s"}}}, + {"jump_speed", + {{"id", "jump_speed"}, + {"name", "Jump speed"}, + {"type", "number"}, + {"default", 6.0}, + {"min", 0.0}, + {"units", "m/s"}}}}; + for (const auto* id : {"gate", "goal", "win_marker", "token_1", "token_2", "token_3"}) + fields[id] = {{"id", id}, {"name", id}, {"type", "entity_ref"}, {"default", id}}; + auto types = nlohmann::json::array({{{"id", collectorType}, + {"name", "Collect three and escape"}, + {"version", 1}, + {"fields", std::move(fields)}}}); + + return types; +} +} // namespace faset::gameplay diff --git a/examples/projects/collect-2d/Scripts/Gameplay.hpp b/examples/projects/collect-2d/Scripts/Gameplay.hpp new file mode 100644 index 0000000..d9caafe --- /dev/null +++ b/examples/projects/collect-2d/Scripts/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/examples/projects/collect-2d/project.faset.json b/examples/projects/collect-2d/project.faset.json new file mode 100644 index 0000000..7eed9d6 --- /dev/null +++ b/examples/projects/collect-2d/project.faset.json @@ -0,0 +1,8 @@ +{ + "format": "faset.project", + "version": 1, + "id": "example-collect-2d-project", + "name": "Collect & Escape 2D", + "dimension": 2, + "start_scene": "Scenes/main.scene.json" +} diff --git a/examples/projects/collect-3d/.gitignore b/examples/projects/collect-3d/.gitignore new file mode 100644 index 0000000..066358d --- /dev/null +++ b/examples/projects/collect-3d/.gitignore @@ -0,0 +1,2 @@ +.faset/ +Exports/ diff --git a/examples/projects/collect-3d/Assets/exit-arch/create.py b/examples/projects/collect-3d/Assets/exit-arch/create.py new file mode 100644 index 0000000..dbd8274 --- /dev/null +++ b/examples/projects/collect-3d/Assets/exit-arch/create.py @@ -0,0 +1,50 @@ +"""Recreate the demo's original exit arch with an unmodified Blender 4.5+. +Run: blender --background --factory-startup --python create.py -- ENGINE_ROOT +""" +import pathlib +import sys +import uuid +import bpy + +root = pathlib.Path(sys.argv[sys.argv.index('--') + 1]).resolve() +output = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(root / 'tools')) +import blender_addon +from blender_addon.bundle import publish_bundle + +blender_addon.register() +bpy.ops.object.select_all(action='SELECT') +bpy.ops.object.delete(use_global=False) +material = bpy.data.materials.new('Weathered stone') +material.diffuse_color = (0.22, 0.29, 0.34, 1) +material.use_nodes = True +shader = material.node_tree.nodes.get('Principled BSDF') +shader.inputs['Base Color'].default_value = material.diffuse_color +shader.inputs['Metallic'].default_value = 0.08 +shader.inputs['Roughness'].default_value = 0.8 +material['faset_id'] = '08046276-106a-4aeb-80f1-d4f02a2ffdc1' +namespace = uuid.UUID('73023ab9-8fe6-4d56-b136-c25c51510b72') +for name, position, size in [ + ('Left post', (0, -1.3, 1.45), (0.45, 0.35, 2.9)), + ('Right post', (0, 1.3, 1.45), (0.45, 0.35, 2.9)), + ('Lintel', (0, 0, 3.0), (0.52, 3.0, 0.35)), +]: + bpy.ops.mesh.primitive_cube_add(location=position) + obj = bpy.context.object + obj.name = name + obj.dimensions = size + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(material) + obj['faset_id'] = str(uuid.uuid5(namespace, name)) + obj.data['faset_id'] = str(uuid.uuid5(namespace, name + ' mesh')) + bevel = obj.modifiers.new('Small stone bevel', 'BEVEL') + bevel.width = 0.06 + bevel.segments = 1 +bpy.context.scene['faset_asset_id'] = '5832763b-3ed0-44d6-9088-0b524f196a91' +bpy.ops.wm.save_as_mainfile(filepath=str(output / 'source.blend')) +raw = output / 'arch.glb' +bpy.ops.export_scene.gltf(filepath=str(raw), export_format='GLB', export_extras=True, + export_yup=True, export_animations=False, export_materials='EXPORT', use_active_scene=True) +publish_bundle(raw, output, bpy.context.scene['faset_asset_id'], bpy.app.version_string, 'source.blend') +raw.unlink() +print('Faset exit arch recreated') diff --git a/examples/projects/collect-3d/Assets/exit-arch/manifest.json b/examples/projects/collect-3d/Assets/exit-arch/manifest.json new file mode 100644 index 0000000..e303569 --- /dev/null +++ b/examples/projects/collect-3d/Assets/exit-arch/manifest.json @@ -0,0 +1,69 @@ +{ + "asset_id": "5832763b-3ed0-44d6-9088-0b524f196a91", + "exporter": { + "addon_version": "0.1.0", + "blender_version": "4.5.3 LTS" + }, + "files": [ + { + "path": "payload/ee6c748a212905fc9c465cbe95c033e94b80be643624f4f240ad5353761205b3.glb", + "sha256": "ee6c748a212905fc9c465cbe95c033e94b80be643624f4f240ad5353761205b3", + "size": 5556 + } + ], + "generation": "7000c0ddc0ef38a87a6bfa1833b757df648aecfbdc3c304036028ba7f530a121", + "outputs": [ + { + "kind": "node", + "locator": "/nodes/0", + "name": "Left post", + "source_id": "4fbe8b04-1247-5088-a438-934d4e26c532" + }, + { + "kind": "node", + "locator": "/nodes/1", + "name": "Right post", + "source_id": "3f82eddf-7169-590e-9116-77f91d809972" + }, + { + "kind": "node", + "locator": "/nodes/2", + "name": "Lintel", + "source_id": "49b46a7c-9ae3-54c1-8e4f-ad4efe627330" + }, + { + "kind": "mesh", + "locator": "/meshes/0", + "name": "Cube.001", + "source_id": "b359b954-1c3e-5ae9-a127-ce251b05193e" + }, + { + "kind": "mesh", + "locator": "/meshes/1", + "name": "Cube.002", + "source_id": "0a83245a-e502-5fd8-978d-dd65931238a3" + }, + { + "kind": "mesh", + "locator": "/meshes/2", + "name": "Cube.003", + "source_id": "6f6587d4-1d8e-56b5-9ec6-afbd6ac51e55" + }, + { + "kind": "material", + "locator": "/materials/0", + "name": "Weathered stone", + "source_id": "08046276-106a-4aeb-80f1-d4f02a2ffdc1" + } + ], + "recipe": { + "export_animations": false, + "export_extras": true, + "export_yup": true, + "profile": "faset-gltf-static-v1" + }, + "schema_version": 1, + "source": { + "path_hint": "source.blend" + } +} \ No newline at end of file diff --git a/examples/projects/collect-3d/Assets/exit-arch/manifest.json.faset-import.json b/examples/projects/collect-3d/Assets/exit-arch/manifest.json.faset-import.json new file mode 100644 index 0000000..523fb1c --- /dev/null +++ b/examples/projects/collect-3d/Assets/exit-arch/manifest.json.faset-import.json @@ -0,0 +1,5 @@ +{ + "asset_id": "5832763b-3ed0-44d6-9088-0b524f196a91", + "schema_version": 1, + "settings": {} +} diff --git a/examples/projects/collect-3d/Assets/exit-arch/payload/ee6c748a212905fc9c465cbe95c033e94b80be643624f4f240ad5353761205b3.glb b/examples/projects/collect-3d/Assets/exit-arch/payload/ee6c748a212905fc9c465cbe95c033e94b80be643624f4f240ad5353761205b3.glb new file mode 100644 index 0000000..93fe7ee Binary files /dev/null and b/examples/projects/collect-3d/Assets/exit-arch/payload/ee6c748a212905fc9c465cbe95c033e94b80be643624f4f240ad5353761205b3.glb differ diff --git a/examples/projects/collect-3d/Assets/exit-arch/source.blend b/examples/projects/collect-3d/Assets/exit-arch/source.blend new file mode 100644 index 0000000..0213b3b Binary files /dev/null and b/examples/projects/collect-3d/Assets/exit-arch/source.blend differ diff --git a/examples/projects/collect-3d/README.md b/examples/projects/collect-3d/README.md new file mode 100644 index 0000000..f6bf274 --- /dev/null +++ b/examples/projects/collect-3d/README.md @@ -0,0 +1,80 @@ +# Collect & Escape 3D + +A small playable project using the real Faset C++ gameplay module and Box3D adapter. The exit arch is an original Blender asset included as a GLB bundle, source `.blend`, and reproducible Blender script. Importing the included bundle does not require Blender. Gameplay geometry and physics use built-in primitives. + +## Play + +Import the included Blender bundle once (repeat after clearing the project cache), then open the project from the repository root: + +```bash +build/linux-debug/faset_editor --project examples/projects/collect-3d \ + --command '{"name":"faset_import","arguments":{"path":"Assets/exit-arch/manifest.json"}}' --wait +``` + +You can also import `Assets/exit-arch/manifest.json` from the Editor's Assets panel before Play. +Open the Editor: + +```bash +build/linux-debug/faset_editor --project examples/projects/collect-3d +``` + +Use **Play** to build this project's `Scripts/Gameplay.cpp` and start a separate Player. On Windows use your Windows build directory and `faset_editor.exe`. First complete the engine build setup in the User Manual. + +For a direct Player build on Linux, from the repository root: + +```bash +cmake -S . -B build/collect-3d -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DFASET_GAMEPLAY_SOURCE_DIR="$PWD/examples/projects/collect-3d/Scripts" +cmake --build build/collect-3d --target faset_player faset_schema_exporter +build/collect-3d/faset_player --scene examples/projects/collect-3d/Scenes/main.scene.json \ + --assets examples/projects/collect-3d/.faset/cache +``` + +On Windows use a Developer shell with `clang-cl` and the Windows SDK, select `clang-cl` for both compiler options, and give `FASET_GAMEPLAY_SOURCE_DIR` an absolute path. + +## Objective and controls + +- WASD or arrow keys move on world X/Z. W points toward negative Z (away from the camera); diagonal speed is normalized. +- **Space** jumps while a native contact supports the player block. +- Collect all three **gold cubes**. Jump onto the raised platform for the middle pickup. +- Each pickup moves to the progress display. After all three, the **red physical gate** moves out of the level. +- Reach the **green exit** to reveal the large gold victory marker. Movement stops after completion. +- **E** restores the player, pickups, gate, and victory state. Falling below the level also resets the round. +- **P** pauses, **N** advances one fixed tick, and **Escape** closes the Player. + +The Player window title and console identify the controls and objective; the game uses geometric progress markers rather than a text HUD. The player is a dynamic box with zero friction, not an articulated character controller. It can rotate after impacts. + +## Inspect and change the game + +`project.faset.json` chooses `Scenes/main.scene.json`. The scene stores object/component IDs, transforms, physics settings, and entity references in the collector component. `Scripts/Gameplay.cpp` owns the round's transient C++ state and registers its editable schema. `speed` and `jump_speed` use metres per second. + +Edit the scene through the Inspector and restart Play to see new level data. Change C++ or its schema, then stop, build, and restart; C++ hot reload is not implemented. Runtime pickup progress does not rewrite the authoring scene or create Undo entries. + +The project vendors `Scripts/Extensions/Beacon.hpp` from the engine's extension example. Its separate `example.beacon` schema and callback rotate the pickups and victory marker. This is a C++ header package explicitly registered by the game, not an Editor plugin or automatic reflection. + +## Verify + +The normal test build creates `faset_example_3d_tests`. It compiles this exact module, follows a route using movement/jump input, collects the elevated pickup, opens the gate, reaches the exit, resets the round, and restarts the scene. It does not teleport to pass the objective. + +```bash +cmake --build build/linux-debug --target faset_example_3d_tests +ctest --test-dir build/linux-debug -R '^playable_3d$' --output-on-failure +``` + +The runtime test is independent of graphics. A Player run additionally requires the supported Vulkan platform setup. The Editor's Export action creates a separate Release build and a cooked standalone generation; run that generation's Player from its export directory. + +## Recreate the Blender asset + +With an unmodified Blender 4.5 or later and the engine repository as the current directory: + +```bash +blender --background --factory-startup --python-exit-code 1 \ + --python examples/projects/collect-3d/Assets/exit-arch/create.py -- "$PWD" +``` + +The script creates the three beveled stone pieces with persistent UUIDs, saves +`source.blend`, and publishes a checksum-verified GLB manifest. Reimport the manifest +after editing it. Placement and gameplay stay in the Faset scene. The arch is visual; +the separate red gate supplies the gameplay collision. diff --git a/examples/projects/collect-3d/Scenes/main.scene.json b/examples/projects/collect-3d/Scenes/main.scene.json new file mode 100644 index 0000000..6a1e831 --- /dev/null +++ b/examples/projects/collect-3d/Scenes/main.scene.json @@ -0,0 +1,845 @@ +{ + "format": "faset.scene", + "version": 1, + "id": "example-collect-3d-scene", + "name": "Collect & Escape 3D \u2014 WASD / Space / E reset", + "dimension": 3, + "simulation": { + "fixed_delta": 0.016666666666666666, + "max_catch_up_ticks": 4, + "physics_substeps": 4, + "gravity": [ + 0, + -9.81, + 0 + ] + }, + "entities": [ + { + "id": "floor", + "name": "Room floor", + "parent": null, + "components": [ + { + "id": "floor-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + -0.75, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 18, + 0.5, + 12 + ] + } + }, + { + "id": "floor-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ] + } + }, + { + "id": "floor-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "platform", + "name": "Jump platform", + "parent": null, + "components": [ + { + "id": "platform-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -1, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 2, + 1, + 2 + ] + } + }, + { + "id": "platform-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.4, + 0.45, + 0.55, + 1 + ] + } + }, + { + "id": "platform-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "back-wall", + "name": "Back wall", + "parent": null, + "components": [ + { + "id": "back-wall-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0, + -6 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 18, + 1, + 0.4 + ] + } + }, + { + "id": "back-wall-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ] + } + }, + { + "id": "back-wall-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "left-wall", + "name": "Left wall", + "parent": null, + "components": [ + { + "id": "left-wall-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -9, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 0.4, + 1, + 12 + ] + } + }, + { + "id": "left-wall-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ] + } + }, + { + "id": "left-wall-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "right-wall", + "name": "Right wall", + "parent": null, + "components": [ + { + "id": "right-wall-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 9, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 0.4, + 1, + 12 + ] + } + }, + { + "id": "right-wall-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ] + } + }, + { + "id": "right-wall-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "front-wall", + "name": "Front wall", + "parent": null, + "components": [ + { + "id": "front-wall-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0, + 6 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 18, + 1, + 0.4 + ] + } + }, + { + "id": "front-wall-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.2, + 0.3, + 0.39, + 1 + ] + } + }, + { + "id": "front-wall-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "token_1", + "name": "Gold pickup 1", + "parent": null, + "components": [ + { + "id": "token_1-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -5, + 0.35, + -3 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 0.5, + 0.5, + 0.5 + ] + } + }, + { + "id": "token_1-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 1, + 0.68, + 0.14, + 1 + ] + } + }, + { + "id": "token_1-example.beacon", + "type": "example.beacon", + "version": 1, + "fields": { + "speed": 1.0 + } + } + ] + }, + { + "id": "token_2", + "name": "Gold pickup 2", + "parent": null, + "components": [ + { + "id": "token_2-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -1, + 1.35, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 0.5, + 0.5, + 0.5 + ] + } + }, + { + "id": "token_2-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 1, + 0.68, + 0.14, + 1 + ] + } + }, + { + "id": "token_2-example.beacon", + "type": "example.beacon", + "version": 1, + "fields": { + "speed": 1.25 + } + } + ] + }, + { + "id": "token_3", + "name": "Gold pickup 3", + "parent": null, + "components": [ + { + "id": "token_3-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 3, + 0.35, + 3 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 0.5, + 0.5, + 0.5 + ] + } + }, + { + "id": "token_3-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 1, + 0.68, + 0.14, + 1 + ] + } + }, + { + "id": "token_3-example.beacon", + "type": "example.beacon", + "version": 1, + "fields": { + "speed": 1.5 + } + } + ] + }, + { + "id": "gate", + "name": "Collect all three to open", + "parent": null, + "components": [ + { + "id": "gate-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 6, + 1, + -4 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 0.45, + 3, + 3 + ] + } + }, + { + "id": "gate-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.9, + 0.23, + 0.16, + 1 + ] + } + }, + { + "id": "gate-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.4, + "restitution": 0, + "gravity_scale": 1 + } + } + ] + }, + { + "id": "goal", + "name": "Green exit", + "parent": null, + "components": [ + { + "id": "goal-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 7.5, + 0.15, + -4 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 0.85, + 0.3, + 0.85 + ] + } + }, + { + "id": "goal-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.15, + 0.9, + 0.47, + 1 + ] + } + } + ] + }, + { + "id": "win_marker", + "name": "Victory marker", + "parent": null, + "components": [ + { + "id": "win_marker-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 7.5, + 3.0, + -4 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "win_marker-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 1, + 0.68, + 0.14, + 1 + ] + } + }, + { + "id": "win_marker-example.beacon", + "type": "example.beacon", + "version": 1, + "fields": { + "speed": 2 + } + } + ] + }, + { + "id": "player", + "name": "Blue player block", + "parent": null, + "components": [ + { + "id": "player-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + -6, + 0, + 3 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "player-faset.mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "builtin:cube", + "color": [ + 0.12, + 0.65, + 1, + 1 + ] + } + }, + { + "id": "player-faset.rigid_body_3d", + "type": "faset.rigid_body_3d", + "version": 1, + "fields": { + "body_type": "dynamic", + "half_extents": [ + 0.5, + 0.5, + 0.5 + ], + "density": 1, + "friction": 0, + "restitution": 0, + "gravity_scale": 1 + } + }, + { + "id": "player-example.collector_3d", + "type": "example.collector_3d", + "version": 1, + "fields": { + "speed": 4, + "jump_speed": 6, + "gate": "gate", + "goal": "goal", + "win_marker": "win_marker", + "token_1": "token_1", + "token_2": "token_2", + "token_3": "token_3" + } + } + ] + }, + { + "id": "camera", + "name": "Room camera", + "parent": null, + "components": [ + { + "id": "camera-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 11, + 13, + 18 + ], + "rotation": [ + -0.5522890193034389, + 0.5485494024505281, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "camera-faset.camera", + "type": "faset.camera", + "version": 1, + "fields": { + "fov": 50, + "near": 0.1, + "far": 200 + } + } + ] + }, + { + "id": "blender-exit-arch", + "name": "Blender exit arch", + "parent": null, + "components": [ + { + "id": "blender-exit-arch-pose", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 7.4, + -0.5, + -4 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "blender-exit-arch-mesh", + "type": "faset.mesh", + "version": 1, + "fields": { + "asset": "5832763b-3ed0-44d6-9088-0b524f196a91", + "primitive": "asset", + "color": [ + 1, + 1, + 1, + 1 + ] + } + } + ] + } + ], + "instances": [] +} diff --git a/examples/projects/collect-3d/Scripts/Extensions/Beacon.hpp b/examples/projects/collect-3d/Scripts/Extensions/Beacon.hpp new file mode 100644 index 0000000..4126395 --- /dev/null +++ b/examples/projects/collect-3d/Scripts/Extensions/Beacon.hpp @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace beacon { +inline nlohmann::json schema() { + return {{"id", "example.beacon"}, + {"name", "Beacon"}, + {"version", 1}, + {"fields", + {{"speed", + {{"id", "speed"}, + {"name", "Rotation speed"}, + {"type", "number"}, + {"default", 1.0}, + {"units", "rad/s"}}}}}}; +} +inline void register_behavior(faset::runtime::Runtime& runtime) { + faset::runtime::Behavior behavior; + behavior.fixedUpdate = [](faset::runtime::Runtime& world, faset::runtime::EntityHandle self, + double dt) { + auto pose = world.transform(self); + pose.rotation[1] += + world.fields(self, "example.beacon").value("speed", 1.0f) * static_cast(dt); + world.setTransform(self, pose); + }; + runtime.registerBehavior("example.beacon", std::move(behavior)); +} +} // namespace beacon diff --git a/examples/projects/collect-3d/Scripts/Gameplay.cpp b/examples/projects/collect-3d/Scripts/Gameplay.cpp new file mode 100644 index 0000000..08a598b --- /dev/null +++ b/examples/projects/collect-3d/Scripts/Gameplay.cpp @@ -0,0 +1,157 @@ +#include "Gameplay.hpp" +#include "Extensions/Beacon.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace faset::gameplay { +namespace { +constexpr bool is3D = true; +constexpr const char* collectorType = "example.collector_3d"; +using Key = std::tuple; +Key key(runtime::EntityHandle handle) { + return {handle.session, handle.slot, handle.generation}; +} +struct Round { + runtime::Transform spawn, gatePose, victoryPose; + runtime::EntityHandle gate, goal, victory; + std::array tokens; + std::array tokenPoses; + std::array collected{}; + bool won{}; + bool gateOpened{}; +}; +float distance(runtime::Vec3 a, runtime::Vec3 b) { + const float x = a[0] - b[0], y = a[1] - b[1], z = is3D ? a[2] - b[2] : 0; + return std::sqrt(x * x + y * y + z * z); +} +void reset(runtime::Runtime& game, runtime::EntityHandle self, Round& round) { + game.teleport(self, round.spawn); + game.setVelocity(self, {0, 0, 0}); + game.teleport(round.gate, round.gatePose); + for (std::size_t i = 0; i < 3; ++i) + game.setTransform(round.tokens[i], round.tokenPoses[i]); + auto hidden = round.victoryPose; + hidden.position[1] = -50; + game.setTransform(round.victory, hidden); + round.collected = {}; + round.won = false; + round.gateOpened = false; + std::cout << "New round: collect the three gold cubes, then reach the green exit. E resets.\n"; +} +} // namespace + +void registerGameplay(runtime::Runtime& world) { + beacon::register_behavior(world); + auto rounds = std::make_shared>(); + runtime::Behavior collector; + collector.onStart = [rounds](runtime::Runtime& game, runtime::EntityHandle self, double) { + const auto fields = game.fields(self, collectorType); + Round round; + round.spawn = game.transform(self); + auto resolve = [&](const char* field) { + const auto handle = game.find(fields.at(field).get()); + if (!game.valid(handle)) + throw std::runtime_error(std::string("Missing level reference: ") + field); + return handle; + }; + round.gate = resolve("gate"); + round.goal = resolve("goal"); + round.victory = resolve("win_marker"); + round.gatePose = game.transform(round.gate); + round.victoryPose = game.transform(round.victory); + for (std::size_t i = 0; i < 3; ++i) { + const auto field = "token_" + std::to_string(i + 1); + round.tokens[i] = resolve(field.c_str()); + round.tokenPoses[i] = game.transform(round.tokens[i]); + } + auto& stored = rounds->insert_or_assign(key(self), std::move(round)).first->second; + reset(game, self, stored); + }; + collector.onDestroy = [rounds](runtime::Runtime&, runtime::EntityHandle self, double) { + rounds->erase(key(self)); + }; + collector.fixedUpdate = [rounds](runtime::Runtime& game, runtime::EntityHandle self, double) { + auto& round = rounds->at(key(self)); + const auto input = game.input(); + const auto position = game.transform(self).position; + if (input.interactPressed || position[1] < -8) { + reset(game, self, round); + return; + } + const auto fields = game.fields(self, collectorType); + auto velocity = game.velocity(self); + float x = input.horizontal, z = is3D ? -input.vertical : 0; + const float length = std::sqrt(x * x + z * z); + if (length > 1) { + x /= length; + z /= length; + } + const auto speed = fields.value("speed", 4.0f); + velocity[0] = round.won ? 0 : x * speed; + if (is3D) + velocity[2] = round.won ? 0 : z * speed; + if (!round.won && input.jumpPressed && game.grounded(self)) + velocity[1] = fields.value("jump_speed", 6.0f); + game.setVelocity(self, velocity); + if (round.won) + return; + for (std::size_t i = 0; i < 3; ++i) + if (!round.collected[i] && distance(position, round.tokenPoses[i].position) < 0.95f) { + round.collected[i] = true; + auto display = round.tokenPoses[i]; + display.position = is3D ? runtime::Vec3{6, 2 + float(i) * 0.8f, -4} + : runtime::Vec3{-1.2f + float(i) * 1.2f, 4.5f, 0}; + game.setTransform(round.tokens[i], display); + std::cout << "Collected " + << std::count(round.collected.begin(), round.collected.end(), true) + << "/3\n"; + } + if (std::all_of(round.collected.begin(), round.collected.end(), + [](bool value) { return value; })) { + if (!round.gateOpened) { + auto open = round.gatePose; + open.position[1] = -30; + game.teleport(round.gate, open); + round.gateOpened = true; + } + if (distance(position, game.transform(round.goal).position) < 1.0f) { + round.won = true; + game.setTransform(round.victory, round.victoryPose); + std::cout << "Level complete! The gold victory marker is visible. Press E to play " + "again.\n"; + } + } + }; + world.registerBehavior(collectorType, std::move(collector)); +} + +nlohmann::json schema() { + nlohmann::json fields = {{"speed", + {{"id", "speed"}, + {"name", "Move speed"}, + {"type", "number"}, + {"default", 4.0}, + {"min", 0.0}, + {"units", "m/s"}}}, + {"jump_speed", + {{"id", "jump_speed"}, + {"name", "Jump speed"}, + {"type", "number"}, + {"default", 6.0}, + {"min", 0.0}, + {"units", "m/s"}}}}; + for (const auto* id : {"gate", "goal", "win_marker", "token_1", "token_2", "token_3"}) + fields[id] = {{"id", id}, {"name", id}, {"type", "entity_ref"}, {"default", id}}; + auto types = nlohmann::json::array({{{"id", collectorType}, + {"name", "Collect three and escape"}, + {"version", 1}, + {"fields", std::move(fields)}}}); + types.push_back(beacon::schema()); + return types; +} +} // namespace faset::gameplay diff --git a/examples/projects/collect-3d/Scripts/Gameplay.hpp b/examples/projects/collect-3d/Scripts/Gameplay.hpp new file mode 100644 index 0000000..d9caafe --- /dev/null +++ b/examples/projects/collect-3d/Scripts/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/examples/projects/collect-3d/project.faset.json b/examples/projects/collect-3d/project.faset.json new file mode 100644 index 0000000..11b8b4e --- /dev/null +++ b/examples/projects/collect-3d/project.faset.json @@ -0,0 +1,8 @@ +{ + "format": "faset.project", + "version": 1, + "id": "example-collect-3d-project", + "name": "Collect & Escape 3D", + "dimension": 3, + "start_scene": "Scenes/main.scene.json" +} diff --git a/include/faset/authoring/service.hpp b/include/faset/authoring/service.hpp index 461414f..cd0f27f 100644 --- a/include/faset/authoring/service.hpp +++ b/include/faset/authoring/service.hpp @@ -9,6 +9,7 @@ namespace faset::authoring { Json make_scene(std::string name, int dimension = 3); +Json default_simulation_settings(); Json make_entity(const SchemaRegistry& schemas, std::string name, const std::string& parent = ""); void validate_scene(const Json& scene, const SchemaRegistry& schemas); diff --git a/include/faset/editor/editor_ui.hpp b/include/faset/editor/editor_ui.hpp index e0e984e..a15db0e 100644 --- a/include/faset/editor/editor_ui.hpp +++ b/include/faset/editor/editor_ui.hpp @@ -20,6 +20,8 @@ class EditorUI { void select_document(const std::string& document); const std::string& selected_entity() const; void select_entity(const std::string& entity); + bool project_switch_requested() const; + void set_project_switch_enabled(bool enabled); private: struct Impl; diff --git a/include/faset/editor/mcp.hpp b/include/faset/editor/mcp.hpp index 931e834..bab2e32 100644 --- a/include/faset/editor/mcp.hpp +++ b/include/faset/editor/mcp.hpp @@ -23,6 +23,8 @@ class StdioTransport { bool closed() const noexcept { return closed_; } + // Write failure closes this transport; a disconnected client never exits + // the Editor process. A GUI can continue after closed() becomes true. void send(const Json& message); private: diff --git a/include/faset/editor/project_launcher.hpp b/include/faset/editor/project_launcher.hpp new file mode 100644 index 0000000..aca2fb7 --- /dev/null +++ b/include/faset/editor/project_launcher.hpp @@ -0,0 +1,34 @@ +#pragma once +#include +#include + +namespace faset::editor { +struct ProjectSelection { + std::filesystem::path path; + std::string name; + int dimension = 3; + bool create = false; +}; +// Records only existing, valid projects. Call after successful Session setup. +void remember_project(const std::filesystem::path& project); +class ProjectLauncher { + public: + ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root, + const std::filesystem::path& initial_project = {}, + const std::filesystem::path& recent_store = {}); + ~ProjectLauncher(); + void frame(const std::vector&); + const render::Snapshot& snapshot() const; + ui::Context& widgets(); + const std::optional& selection() const; + bool cancelled() const; + + private: + struct Impl; + std::unique_ptr impl_; +}; +std::optional +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 = {}); +} // namespace faset::editor diff --git a/include/faset/editor/session.hpp b/include/faset/editor/session.hpp index 93dc2ad..16ca103 100644 --- a/include/faset/editor/session.hpp +++ b/include/faset/editor/session.hpp @@ -46,6 +46,8 @@ class Session { void register_commands(); Json assets_list() const; Json jobs() const; + std::string source_signature() const; + Json schema_status() const; Json job(const std::string& id) const; void load_schema(const std::filesystem::path& path); void launch_player(Json scene, const std::filesystem::path& executable); @@ -65,5 +67,9 @@ class Session { std::string pending_play_job_; Json pending_play_scene_; std::unique_ptr plugins_; + std::map submitted_sources_; + std::string schema_source_signature_; + bool schema_loaded_ = false; + std::string schema_error_; }; } // namespace faset::editor diff --git a/include/faset/player/SceneView.hpp b/include/faset/player/SceneView.hpp index 6fac052..00e45a0 100644 --- a/include/faset/player/SceneView.hpp +++ b/include/faset/player/SceneView.hpp @@ -24,6 +24,11 @@ class SceneView { SceneView& operator=(const SceneView&) = delete; render::Snapshot build(const nlohmann::json& flatSceneOrRuntimeSnapshot, float aspect, CameraSettings camera = {}); + // Append box outlines from explicitly supplied physics poses/settings. The + // Player supplies current simulation poses, independent of visual interpolation. + // Supports the initial root-only Box2D/Box3D adapters; does not change the camera. + void appendPhysicsDebug(render::Snapshot&, const nlohmann::json& physicsScene, + float thickness = 0.025f) const; void clearCache(); const std::vector& diagnostics() const; diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index d3dec8b..3ec9008 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -79,11 +79,16 @@ struct Snapshot { std::vector ui_quads; std::vector ui_text; }; +// CPU-only validation used before publishing a game or creating Vulkan pipelines. +void validate_shader_bundle(const std::filesystem::path& directory); + struct RendererConfig { std::uint32_t width{1280}, height{720}; std::string title{"Faset Engine"}; bool headless{false}; bool validation{true}; + // Optional isolated shader bundle, useful for editor preview and shader reload tests. + std::filesystem::path shader_directory; }; struct Event { enum class Type { @@ -110,8 +115,12 @@ struct Event { }; struct FrameStats { std::uint64_t frame{}; + bool validation_enabled{}; + // Live VkDeviceMemory allocation sizes, including alignment; excludes driver internals. + std::uint64_t gpu_allocated_bytes{}; + std::uint32_t texture_count{}; std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{}; - double cpu_ms{}, gpu_ms{}; + double cpu_ms{}, gpu_ms{}, readback_cpu_ms{}; std::string device; }; class Renderer { diff --git a/include/faset/ui/ui.hpp b/include/faset/ui/ui.hpp index 4768f88..6f2f878 100644 --- a/include/faset/ui/ui.hpp +++ b/include/faset/ui/ui.hpp @@ -118,6 +118,7 @@ struct Widget { bool visible = true, enabled = true, selected = false, checked = false; double value = 0, step = .01; int precision = 3, indent = 0; + float font_size = 0; // Zero inherits the theme typography. float scroll_y = 0, content_height = 0; std::string error, tooltip; Json drag_payload; @@ -159,6 +160,7 @@ class Context { const Theme& theme() const; // Reloads declarative widget properties; matching IDs retain callbacks and // edit state. + void validate_layout(const Json&) const; void apply_layout(const Json&); void layout(float drawable_width, float drawable_height, float dpi_scale = 1); bool handle(const render::Event&); diff --git a/mkdocs.yml b/mkdocs.yml index 2b04696..72f359a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,7 +39,12 @@ nav: - Physics and grounded movement: scripting/physics.md - Runtime API: scripting/api.md - Compiled examples: scripting/examples.md - - Editor automation: + - Editor and assets: + - Editor workspace: editor/workspace.md + - Scene templates: editor/templates.md + - Assets and Blender: editor/assets.md + - Build, Play, and export: editor/export.md + - Profiling and measurements: editor/profiling.md - MCP and command line: editor/mcp.md - Native extensions: editor/extensions.md - Contributing to this manual: contributing.md diff --git a/src/assets/asset_data.cpp b/src/assets/asset_data.cpp index e57f48a..b01b579 100644 --- a/src/assets/asset_data.cpp +++ b/src/assets/asset_data.cpp @@ -168,6 +168,10 @@ CookedAsset AssetStore::load_asset(const std::string& id) const { asset.meshes.push_back(std::move(mesh)); } for (const auto& j : m.at("materials")) { + // Early development manifests embedded v1 materials without a tag. + // Preserve readability, but never interpret an explicitly unknown version. + if (j.value("format", "faset.material") != "faset.material" || j.value("version", 1) != 1) + throw std::runtime_error("Unsupported cooked material format or version"); Material material; material.id = j.at("id"); material.name = j.at("name"); diff --git a/src/assets/asset_pipeline.cpp b/src/assets/asset_pipeline.cpp index aec3e62..1cbfaf3 100644 --- a/src/assets/asset_pipeline.cpp +++ b/src/assets/asset_pipeline.cpp @@ -316,7 +316,9 @@ void validate_generation(const fs::path& directory, const Json& manifest) { } } Json material_json(const Material& m) { - return {{"id", m.id}, + return {{"format", "faset.material"}, + {"version", 1}, + {"id", m.id}, {"name", m.name}, {"base_color", m.base_color}, {"metallic", m.metallic}, @@ -407,6 +409,10 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob const auto source_hash = hash_bytes(source_bytes); const auto sidecar = fs::path(logical_source.string() + ".faset-import.json"); Json metadata = fs::exists(sidecar) ? read_json(sidecar) : Json::object(); + if (!metadata.is_object() || + (!metadata.empty() && metadata.value("schema_version", 0) != 1)) + throw std::runtime_error( + "Unsupported import settings version; source metadata is unchanged"); const auto settings = request.settings.is_null() ? metadata.value("settings", Json::object()) : request.settings; @@ -446,6 +452,17 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob throw std::runtime_error("Encoded image exceeds decoder limit"); const auto* encoded = reinterpret_cast(source_bytes.data()); const auto length = static_cast(source_bytes.size()); + const bool png_signature = + source_bytes.size() >= 8 && source_bytes[0] == std::byte{137} && + source_bytes[1] == std::byte{80} && source_bytes[2] == std::byte{78} && + source_bytes[3] == std::byte{71} && source_bytes[4] == std::byte{13} && + source_bytes[5] == std::byte{10} && source_bytes[6] == std::byte{26} && + source_bytes[7] == std::byte{10}; + const bool jpeg_signature = + source_bytes.size() >= 3 && source_bytes[0] == std::byte{255} && + source_bytes[1] == std::byte{216} && source_bytes[2] == std::byte{255}; + if ((extension == ".png" && !png_signature) || (extension != ".png" && !jpeg_signature)) + throw std::runtime_error("Image content does not match PNG/JPEG extension"); int width = 0, height = 0, channels = 0; if (!stbi_info_from_memory(encoded, length, &width, &height, &channels)) throw std::runtime_error("Invalid PNG/JPEG image header"); @@ -677,10 +694,13 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob asset.textures.push_back(std::move(out)); } } - Json key{{"source", source_hash}, - {"settings", settings}, - {"importer", recipe_version}, - {"dependencies", Json::object()}}; + Json key{ + {"source", source_hash}, + {"settings", settings}, + {"importer", recipe_version}, + {"target_profile", standalone_image ? "desktop-image-v1" : "desktop-static-pbr-v1"}, + {"toolchain", {{"cgltf", FASET_CGLTF_COMMIT}, {"stb", FASET_STB_COMMIT}}}, + {"dependencies", Json::object()}}; if (source != logical_source) key["bundle_sha256"] = logical_hash; for (const auto& [name, item] : dependencies) diff --git a/src/authoring/service.cpp b/src/authoring/service.cpp index 4688b71..ff297a6 100644 --- a/src/authoring/service.cpp +++ b/src/authoring/service.cpp @@ -48,6 +48,12 @@ bool valid_id(const Json& value) { std::string::npos; } } // namespace +Json default_simulation_settings() { + return {{"fixed_delta", 1.0 / 60.0}, + {"max_catch_up_ticks", 4}, + {"physics_substeps", 4}, + {"gravity", {0, -9.81, 0}}}; +} Json make_scene(std::string name, int dimension) { require(dimension == 2 || dimension == 3, "scene.dimension", "Scene dimension must be 2 or 3"); return {{"format", "faset.scene"}, {"version", 1}, {"id", new_id()}, @@ -77,6 +83,26 @@ void validate_scene(const Json& scene, const SchemaRegistry& schemas) { require(scene.contains("entities") && scene["entities"].is_array(), "scene.entities", "Scene entities must be an array"); require(finite_json(scene), "validation.finite", "Scene contains a non-finite number"); + if (scene.contains("simulation")) { + const auto& settings = scene.at("simulation"); + require(settings.is_object(), "simulation.object", "Simulation settings must be an object"); + if (settings.contains("fixed_delta")) + require(settings["fixed_delta"].is_number() && + settings["fixed_delta"].get() > 0 && + settings["fixed_delta"].get() <= 1, + "simulation.fixed_delta", + "Fixed delta must be greater than zero and at most one second"); + for (const auto& [name, maximum] : + std::map{{"max_catch_up_ticks", 1024}, {"physics_substeps", 128}}) + if (settings.contains(name)) { + const auto& value = settings[name]; + require(value.is_number_integer() && value.get() >= 1 && + value.get() <= maximum, + "simulation.integer", "Invalid simulation setting: " + name); + } + if (settings.contains("gravity")) + validate_field(settings["gravity"], {{"type", "vec3"}}); + } std::set ids; std::map parents; auto insert_id = [&](const Json& value) { @@ -120,6 +146,70 @@ void validate_scene(const Json& scene, const SchemaRegistry& schemas) { instance["source"].is_string(), "template.instance", "Invalid template instance"); insert_id(instance["id"]); + require(!instance["source"].get_ref().empty(), "template.source", + "Template source cannot be empty"); + auto address = [&](const Json& value, bool field) { + require(value.is_object() && value.contains("object") && valid_id(value["object"]), + "template.address", "Template address needs a stable object ID"); + if (value.contains("path")) { + require(value["path"].is_array(), "template.address", + "Instance path must be an array"); + for (const auto& part : value["path"]) + require(valid_id(part), "template.address", + "Instance path needs stable IDs"); + } + if (field) + require(value.contains("component") && valid_id(value["component"]) && + value.contains("field") && value["field"].is_string() && + !value["field"].get_ref().empty(), + "template.address", + "Field address needs stable component and field IDs"); + }; + for (const auto* collection : {"overrides", "suppressed", "reparents"}) + if (instance.contains(collection)) + require(instance[collection].is_array(), "template.records", + "Template records must be an array"); + for (const auto& record : instance.value("overrides", Json::array())) { + require(record.is_object() && record.contains("address") && + record.contains("value"), + "template.override", "Override needs an address and value"); + address(record["address"], true); + } + for (const auto& record : instance.value("suppressed", Json::array())) + address(record, false); + for (const auto& record : instance.value("reparents", Json::array())) { + require(record.is_object() && record.contains("object") && + record.contains("parent"), + "template.reparent", "Reparent needs object and parent addresses"); + address(record["object"], false); + if (!record["parent"].is_null()) + address(record["parent"], false); + if (record.contains("keep_world")) + require(record["keep_world"].is_boolean(), "template.reparent", + "keep_world must be a boolean"); + } + + if (instance.contains("additions")) { + require(instance["additions"].is_array(), "template.additions", + "Local additions must be an array"); + auto additions = make_scene("Local additions"); + additions["entities"] = instance["additions"]; + // Local cycles are rejected here; parents in the source are checked during + // resolution. + std::set local_ids; + for (const auto& item : additions["entities"]) + if (item.contains("id") && item["id"].is_string()) + local_ids.insert(item["id"].get()); + for (auto& item : additions["entities"]) { + if (item.contains("parent") && !item["parent"].is_null()) + require(valid_id(item["parent"]), "template.addition_parent", + "Local parent must be a stable object ID or null"); + if (!item.contains("parent") || item["parent"].is_null() || + !local_ids.contains(item["parent"].get())) + item["parent"] = nullptr; + } + validate_scene(additions, schemas); + } } } } @@ -239,6 +329,16 @@ void AuthoringService::apply(Json& scene, const Json& command) { scene["entities"].push_back(std::move(value)); } else if (op == "entity.rename") { entity(scene, command.at("entity").get())["name"] = command.at("name"); + } else if (op == "scene.simulation") { + const auto& value = command.at("value"); + require(value.is_object(), "simulation.object", "Simulation settings must be an object"); + const auto defaults = default_simulation_settings(); + for (const auto& [key, setting] : value.items()) + require(defaults.contains(key), "simulation.setting", + "Unknown simulation setting: " + key); + if (!scene.contains("simulation")) + scene["simulation"] = defaults; + scene["simulation"].update(value); } else if (op == "entity.delete") { const auto id = command.at("entity").get(); entity(scene, id); @@ -317,6 +417,8 @@ void AuthoringService::apply(Json& scene, const Json& command) { if (!schemas_.contains(type)) continue; const auto metadata = schemas_.schema(type); + if (component.value("version", 1) != metadata.value("version", 1)) + continue; for (auto& [field, value] : component["fields"].items()) if (metadata["fields"].contains(field) && metadata["fields"][field].value("type", std::string()) == @@ -338,12 +440,49 @@ void AuthoringService::apply(Json& scene, const Json& command) { scene["instances"] = Json::array(); scene["instances"].push_back(std::move(value)); } else if (op == "template.override" || op == "template.revert" || op == "template.suppress" || - op == "template.add" || op == "template.reparent") { + op == "template.add" || op == "template.reparent" || op == "template.remove" || + op == "template.restore" || op == "template.source_set" || + op == "template.addition_set") { auto& instances = scene["instances"]; const auto id = command.at("instance").get(); auto found = std::find_if(instances.begin(), instances.end(), [&](const Json& value) { return value.at("id") == id; }); require(found != instances.end(), "template.missing", "Instance not found"); + if (op == "template.addition_set") { + const auto& replacement = command.at("value"); + const auto addition_id = replacement.at("id"); + auto& additions = (*found)["additions"]; + require(additions.is_array(), "template.addition_missing", + "Instance has no local additions"); + auto addition = + std::find_if(additions.begin(), additions.end(), + [&](const Json& value) { return value.at("id") == addition_id; }); + require(addition != additions.end(), "template.addition_missing", + "Local addition not found"); + *addition = replacement; + return; + } + if (op == "template.remove") { + instances.erase(found); + return; + } + if (op == "template.source_set") { + const auto source = command.at("source").get(); + project_path(root_, source); + (*found)["source"] = source; + return; + } + if (op == "template.restore") { + auto& records = (*found)["suppressed"]; + require(records.is_array(), "template.suppression_missing", + "Instance has no suppressed objects"); + const auto address = command.at("address"); + const auto before = records.size(); + records.erase(std::remove(records.begin(), records.end(), address), records.end()); + require(records.size() != before, "template.suppression_missing", + "Suppressed object address not found"); + return; + } const std::string key = op == "template.suppress" ? "suppressed" : op == "template.add" ? "additions" : op == "template.reparent" ? "reparents" diff --git a/src/authoring/templates.cpp b/src/authoring/templates.cpp index 4843955..a2d9250 100644 --- a/src/authoring/templates.cpp +++ b/src/authoring/templates.cpp @@ -32,14 +32,30 @@ struct Resolver { return &item; return nullptr; } + void remap_references(Json& entity, const std::map& mapping) { + for (auto& component : entity["components"]) { + const auto type = component.at("type").get(); + if (!schemas.contains(type)) + continue; + const auto metadata = schemas.schema(type); + if (component.value("version", 1) != metadata.value("version", 1)) + continue; + for (auto& [field, value] : component["fields"].items()) + if (metadata["fields"].contains(field) && + metadata["fields"][field].value("type", std::string()) == "entity_ref" && + value.is_string() && mapping.contains(value.get())) + value = mapping.at(value.get()); + } + } Json expand(const Json& scene, const Json& path) { require(path.size() <= 32, "template.depth", "Maximum template nesting depth exceeded"); validate_scene(scene, schemas); Json output = Json::array(); - std::map ids; + std::map ids, entity_ids; for (const auto& item : scene["entities"]) { const auto id = item.at("id").get(); ids[id] = path.empty() ? id : scoped_id(root, path, id); + entity_ids[id] = ids[id]; for (const auto& component : item["components"]) { const auto cid = component.at("id").get(); ids[cid] = path.empty() ? cid : scoped_id(root, path, cid); @@ -56,16 +72,8 @@ struct Resolver { const auto source_id = component.at("id").get(); component["id"] = ids.at(source_id); component["source_id"] = source_id; - const auto type = component.at("type").get(); - if (!schemas.contains(type)) - continue; - const auto metadata = schemas.schema(type); - for (auto& [field, value] : component["fields"].items()) - if (metadata["fields"].contains(field) && - metadata["fields"][field].value("type", std::string()) == "entity_ref" && - value.is_string() && ids.contains(value.get())) - value = ids.at(value.get()); } + remap_references(item, entity_ids); output.push_back(std::move(item)); } for (const auto& instance : scene.value("instances", Json::array())) { @@ -89,19 +97,37 @@ struct Resolver { {{"source", source_name}, {"message", error.what()}}); continue; } + std::map local_ids; + for (const auto& entity : expanded) + if (entity.at("origin").at("path") == nested_path) + local_ids[entity.at("origin").at("object").get()] = + entity.at("id").get(); + for (const auto& addition : instance.value("additions", Json::array())) { + const auto id = addition.at("id").get(); + require(!local_ids.contains(id), "template.addition_id_collision", + "Local addition reuses a source object ID"); + local_ids[id] = scoped_id(root, nested_path, id); + } for (const auto& addition : instance.value("additions", Json::array())) { Json item = addition; const auto id = item.at("id").get(); item["id"] = scoped_id(root, nested_path, id); item["origin"] = {{"path", nested_path}, {"object", id}, {"local", true}}; - if (item.contains("parent") && !item["parent"].is_null()) - item["parent"] = - scoped_id(root, nested_path, item["parent"].get()); + if (item.contains("parent") && !item["parent"].is_null()) { + const auto parent = item["parent"].get(); + if (local_ids.contains(parent)) + item["parent"] = local_ids.at(parent); + else { + conflict(nested_path, "addition.parent_missing", addition); + item["parent"] = nullptr; + } + } for (auto& component : item["components"]) { const auto cid = component.at("id").get(); component["source_id"] = cid; component["id"] = scoped_id(root, nested_path, cid); } + remap_references(item, local_ids); expanded.push_back(std::move(item)); } for (const auto& change : instance.value("overrides", Json::array())) { @@ -126,9 +152,25 @@ struct Resolver { conflict(nested_path, "override.field_unavailable", change); continue; } + if (found->value("version", 1) != schemas.schema(type).value("version", 1)) { + conflict(nested_path, "override.schema_version", change); + continue; + } try { validate_field(change.at("value"), schemas.schema(type)["fields"][field]); - (*found)["fields"][field] = change.at("value"); + auto value = change.at("value"); + if (schemas.schema(type)["fields"][field].value("type", std::string()) == + "entity_ref" && + value.is_string()) { + for (const auto& target_entity : expanded) + if (target_entity.at("origin").at("path") == + item->at("origin").at("path") && + target_entity.at("origin").at("object") == value) { + value = target_entity.at("id"); + break; + } + } + (*found)["fields"][field] = std::move(value); } catch (const std::exception& error) { conflict(nested_path, "override.invalid", {{"change", change}, {"message", error.what()}}); diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 8ec51d5..426e25c 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -306,7 +306,9 @@ struct BuildService::Impl { atomic_write_json(schema_file, schema); copy_required_file(player, staging / ("faset_player" + executable_suffix())); copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix())); - for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) + for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv", + "vertexMain.reflection.json", "fragmentMain.reflection.json", + "shadowMain.reflection.json"}) copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file); copy_runtime_libraries(job, player, staging, native_directory, configuration); Json manifest{{"format", "faset.build"}, @@ -520,7 +522,9 @@ struct BuildService::Impl { auto build_directory = fs::path(built.at("directory").get()); copy_required_file(build_directory / ("faset_player" + executable_suffix()), staging / ("faset_player" + executable_suffix())); - for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) + for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv", + "vertexMain.reflection.json", "fragmentMain.reflection.json", + "shadowMain.reflection.json"}) copy_required_file(build_directory / "shaders" / shader, staging / "shaders" / shader); for (const auto& entry : fs::directory_iterator(build_directory)) { diff --git a/src/editor/commands.cpp b/src/editor/commands.cpp index 8f6be50..9086bc8 100644 --- a/src/editor/commands.cpp +++ b/src/editor/commands.cpp @@ -67,8 +67,13 @@ Json Commands::call(const std::string& name, const Json& arguments) { } Json Commands::resolved_scene(const std::string& id) const { const auto result = authoring::resolve_templates( - authoring_.query(id).at("scene"), authoring_.schemas(), - [&](const std::string& path) { return read_json(project_path(authoring_.root(), path)); }); + authoring_.query(id).at("scene"), authoring_.schemas(), [&](const std::string& path) { + const auto relative = std::filesystem::path(path).lexically_normal(); + for (const auto& document : authoring_.documents()) + if (document.at("path") == relative.generic_string()) + return authoring_.query(document.at("id")).at("scene"); + return read_json(project_path(authoring_.root(), relative)); + }); return {{"scene", result.scene}, {"conflicts", result.conflicts}}; } Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authoring) { @@ -109,7 +114,10 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin add("faset_scene_edit", "Apply one atomic authoring batch with optimistic revision checking and one Undo step. " "Operations: entity.create/rename/delete/duplicate/reparent; component.add/remove/set; " - "scene.rename; template.instance/override/revert/suppress/add/reparent. Use persistent IDs " + "scene.rename/simulation; " + "template.instance/override/revert/suppress/restore/add/addition_set/reparent/remove/" + "source_set. Use " + "persistent IDs " "from document_query and schema. An idempotency_key retries the same payload in this " "session.", object_schema({{"document", text}, @@ -138,6 +146,31 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin "is not a live game query.", object_schema({{"document", text}}, {"document"}), [&](const Json& args) { return resolved_scene(args.at("document")); }, true); + add( + "faset_simulation_get", + "Read scene simulation settings with defaults: fixed_delta seconds, max_catch_up_ticks, " + "physics_substeps and gravity.", + object_schema({{"document", text}}, {"document"}), + [&](const Json& args) { + const auto document = authoring_.query(args.at("document")); + auto settings = authoring::default_simulation_settings(); + settings.update(document.at("scene").value("simulation", Json::object())); + return Json{{"document", document.at("id")}, + {"revision", document.at("revision")}, + {"settings", settings}}; + }, + true); + add("faset_simulation_set", + "Update scene simulation settings as one Undo transaction. Values apply when the Player " + "next starts.", + object_schema( + {{"document", text}, {"revision", integer}, {"settings", {{"type", "object"}}}}, + {"document", "revision", "settings"}), + [&](const Json& args) { + return authoring_.transact( + args.at("document"), args.at("revision"), + Json::array({{{"op", "scene.simulation"}, {"value", args.at("settings")}}})); + }); add("faset_recovery_restore", "Restore a recovery journal, including an unsaved new scene. If the document is already " "open, pass its current revision. External file changes are never overwritten.", diff --git a/src/editor/editor_ui.cpp b/src/editor/editor_ui.cpp index d137272..2eb0488 100644 --- a/src/editor/editor_ui.cpp +++ b/src/editor/editor_ui.cpp @@ -47,6 +47,15 @@ Vec3 vector(const Mat4& m, 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]}; } +Vec3 euler_xyz(const Mat4& rotation) { + // The authoring transform is Rz * Ry * Rx. Reconstruct that convention + // after composing an intrinsic rotation instead of adding Euler channels. + const auto cy = std::hypot(rotation[0], rotation[1]); + if (cy > .00001f) + return {std::atan2(rotation[6], rotation[10]), std::atan2(-rotation[2], cy), + std::atan2(rotation[1], rotation[0])}; + return {std::atan2(-rotation[9], rotation[5]), std::atan2(-rotation[2], cy), 0}; +} bool inverse(const Mat4& m, Mat4& out) { double a[4][8]{}; for (int r = 0; r < 4; ++r) { @@ -109,7 +118,7 @@ Mat4 world(const Json& scene, const Json& e, int depth = 0) { if (depth > 256) return render::identity; Mat4 local = render::identity; - if (auto* t = component(e, "faset.transform")) { + if (auto* t = component(e, "faset.transform"); t && t->value("version", 1) == 1) { const auto& f = t->at("fields"); local = render::transform(vec(f.value("position", Json{})), vec(f.value("rotation", Json{})), @@ -154,7 +163,9 @@ struct EditorUI::Impl { ui::DockLayout dock; player::SceneView view; render::Snapshot rendered; - std::string picks_key; + std::string picks_key, resolved_stamp, last_document; + std::set seen_view_diagnostics; + 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"; @@ -164,6 +175,17 @@ struct EditorUI::Impl { std::map edit_revisions; std::map preview_fields; std::filesystem::path layout_path; + std::filesystem::path theme_source_path, layout_source_path; + std::string attempted_theme, attempted_layout, applied_theme, applied_layout, + presentation_error; + std::chrono::steady_clock::time_point last_presentation_poll{}; + bool simulation_open = false; + bool project_settings_open = false; + Json project_settings_state; + int project_settings_dimension = 3; + std::string project_settings_error; + bool project_switch_enabled = true, project_switch_requested = false, + project_switch_warning = false; bool palette = false, component_menu = false, assets_dirty = true, paused = false; float yaw = .65f, pitch = .42f, distance = 12, ortho = 12, mouse_x = 0, mouse_y = 0, last_x = 0, last_y = 0; @@ -188,12 +210,18 @@ struct EditorUI::Impl { Impl(Session& s, render::Renderer& r, const std::filesystem::path& font, const std::filesystem::path& styles) : session(s), renderer(r), ui(font), view(s.config().project_root / ".faset/cache") { - ui.set_theme(ui::Theme::load(styles)); - ui.apply_layout(read_json(styles.parent_path() / "editor-layout.json")); + theme_source_path = styles; + layout_source_path = styles.parent_path() / "editor-layout.json"; + attempted_theme = applied_theme = read_text(theme_source_path); + attempted_layout = applied_layout = read_text(layout_source_path); + ui.set_theme(ui::Theme::from_json(Json::parse(applied_theme))); + ui.apply_layout(Json::parse(applied_layout)); + last_presentation_poll = std::chrono::steady_clock::now(); layout_path = session.config().project_root / ".faset/editor-layout.json"; dock.move("assets", "bottom", 0); dock.move("console", "bottom", 1); dock.move("jobs", "bottom", 2); + dock.move("conflicts", "bottom", 3); if (std::filesystem::exists(layout_path)) try { dock.load(layout_path); @@ -237,6 +265,45 @@ struct EditorUI::Impl { report(e.what()); } } + void poll_presentation() { + const auto now = std::chrono::steady_clock::now(); + if (now - last_presentation_poll < std::chrono::milliseconds(500)) + return; + last_presentation_poll = now; + try { + const auto theme_text = read_text(theme_source_path); + const auto layout_text = read_text(layout_source_path); + if (theme_text == attempted_theme && layout_text == attempted_layout) { + if (!presentation_error.empty() && theme_text == applied_theme && + layout_text == applied_layout) { + presentation_error.clear(); + report("UI theme/layout sources restored"); + } + return; + } + attempted_theme = theme_text; + attempted_layout = layout_text; + const auto theme = ui::Theme::from_json(Json::parse(theme_text)); + const auto layout = Json::parse(layout_text); + // Both candidates are fully validated before changing either. Retained + // IDs, callbacks and dirty field edits stay in the existing Context. + ui.validate_layout(layout); + if (layout_text != applied_layout) + ui.apply_layout(layout); + if (theme_text != applied_theme) + ui.set_theme(theme); + applied_theme = theme_text; + applied_layout = layout_text; + presentation_error.clear(); + report("UI theme/layout reloaded"); + } catch (const std::exception& error) { + const auto message = std::string("UI reload kept last working styles: ") + error.what(); + if (message != presentation_error) { + presentation_error = message; + report(message); + } + } + } void report(const std::string& message) { status = message; session.log(message); @@ -321,7 +388,7 @@ struct EditorUI::Impl { {"version", 1}, {"fields", session.authoring().schemas().default_fields("faset.sprite")}}); if (transaction(Json::array({{{"op", "entity.create"}, {"entity", object}}}))) - selected = id; + select(id); } void build_static() { auto& menurow = ui.find("menubar")->add(Kind::Row, "menuitems"); @@ -362,6 +429,9 @@ struct EditorUI::Impl { button( toolbar, "export", "Export", [this] { call("faset_export", {{"document", document}, {"output", "Exports"}}); }, 64); + button( + toolbar, "simulation-settings", "Simulation", + [this] { simulation_open = !simulation_open; }, 96); auto& space = toolbar.add(Kind::Label, "toolbar-space", ""); space.layout.flex = 1; button(toolbar, "command-palette", "Commands", [this] { palette = !palette; }, 104); @@ -380,10 +450,7 @@ struct EditorUI::Impl { tree.layout.gap = 0; tree.on_drop = [this](Widget&, const Json& payload) { if (payload.value("kind", std::string()) == "entity") - transaction(Json::array({{{"op", "entity.reparent"}, - {"entity", payload.at("id")}, - {"parent", nullptr}, - {"keep_world", true}}})); + reparent(payload.at("id"), ""); }; auto& inspector = *ui.find("inspector_panel"); inspector.add(Kind::Tab, "inspector-tab", "Inspector").selected = true; @@ -401,6 +468,14 @@ struct EditorUI::Impl { vptools.layout.width = 300; for (const std::string mode : {"Move", "Rotate", "Scale"}) button(vptools, "gizmo-" + mode, mode, [this, mode] { gizmo_mode = mode; }, 64); + button( + vptools, "back-document", "Back", + [this] { + if (!last_document.empty()) + choose_document(last_document); + }, + 54); + vptools.layout.width = 360; button(vptools, "frame-selection", "Frame", [this] { frame_selection(); }, 64); vp.on_drop = [this](Widget&, const Json& data) { if (data.value("kind", std::string()) == "asset") @@ -411,7 +486,7 @@ struct EditorUI::Impl { auto& tabs = bottom.add(Kind::Row, "bottom-tabs"); tabs.layout.height = 29; tabs.layout.gap = 0; - for (const std::string id : {"assets", "console", "jobs"}) { + for (const std::string id : {"assets", "console", "jobs", "conflicts"}) { auto& tab = tabs.add(Kind::Tab, "tab-" + id, id == "assets" ? "Assets" : id == "console" ? "Console" @@ -460,6 +535,10 @@ struct EditorUI::Impl { jobs.layout.flex = 1; jobs.layout.scroll = true; jobs.layout.gap = 1; + auto& conflicts = bottom.add(Kind::Column, "conflict-items"); + conflicts.layout.flex = 1; + conflicts.layout.scroll = true; + conflicts.layout.gap = 3; auto& statusrow = ui.find("statusbar")->add(Kind::Row, "status-row"); auto& statuslabel = statusrow.add(Kind::Label, "status", "Ready"); statuslabel.layout.flex = 1; @@ -479,6 +558,25 @@ struct EditorUI::Impl { label(pop, "menu-title", "File"); button(pop, "new-3d", "New 3D scene", [this] { new_scene(3); }); button(pop, "new-2d", "New 2D scene", [this] { new_scene(2); }); + button(pop, "project-settings", "Project settings", [this] { open_project_settings(); }); + button(pop, "open-project", "Open / create another project", [this] { + if (!project_switch_enabled) + return; + ui.clear_focus(); + if (ui.editing()) + return; + bool warn = session.playing(); + for (const auto& doc : session.authoring().documents()) + warn = warn || doc.value("dirty", false); + const auto jobs = call("faset_jobs"); + if (!jobs.is_null()) + for (const auto& job : jobs.at("jobs")) + warn = warn || job.value("state", std::string()) == "running" || + job.value("state", std::string()) == "queued"; + project_switch_warning = warn; + project_switch_requested = !warn; + menu.clear(); + }); pop.add(Kind::TextField, "open-path", "Scenes/Main.scene.json"); button(pop, "open-path-button", "Open project-relative scene", [this] { auto result = call("faset_document_open", {{"path", ui.find("open-path")->text}}); @@ -506,6 +604,13 @@ struct EditorUI::Impl { delete_selected(); menu.clear(); }); + pop.add(Kind::TextField, "template-path", "Assets/Templates/Template.scene.json"); + button(pop, "save-template", "Save selection as template", + [this] { save_selection_template(ui.find("template-path")->text); }); + button(pop, "instance-template", "Instance scene at this path", [this] { + instance_scene(ui.find("template-path")->text); + menu.clear(); + }); label(pop, "help-one", "Orbit: right drag. Pan: middle drag. Wheel: zoom."); label(pop, "help-two", "W / E / R: gizmos. F: frame. Ctrl+P: commands."); label(pop, "help-three", "Ctrl+S save. Ctrl+Z / Shift+Z undo / redo."); @@ -538,6 +643,39 @@ struct EditorUI::Impl { } }); button(command, "palette-close", "Close", [this] { palette = false; }); + auto& settings = ui.root().add(Kind::Panel, "simulation-panel"); + settings.layout.absolute = true; + settings.layout.width = 470; + settings.layout.height = 350; + settings.layout.padding = 12; + settings.layout.gap = 5; + settings.visible = false; + label(settings, "simulation-title", "Scene simulation"); + label(settings, "simulation-scope", "Used by the next Player snapshot"); + for (const std::string field : {"tick-rate", "max_catch_up_ticks", "physics_substeps"}) { + auto& row = settings.add(Kind::Row, "simulation-row-" + field); + row.layout.height = 32; + label(row, "simulation-label-" + field, + field == "tick-rate" ? "Fixed tick rate (Hz)" + : field == "max_catch_up_ticks" ? "Maximum catch-up ticks" + : "Physics substeps", + 235); + auto& input = row.add(Kind::NumberField, "simulation-" + field); + input.layout.flex = 1; + input.precision = field == "tick-rate" ? 3 : 0; + input.step = 1; + } + label(settings, "simulation-gravity-label", "Gravity (units / second squared)"); + auto& gravity = settings.add(Kind::Row, "simulation-gravity"); + gravity.layout.height = 32; + for (int axis = 0; axis < 3; ++axis) { + auto& input = + gravity.add(Kind::NumberField, "simulation-gravity-" + std::to_string(axis)); + input.layout.flex = 1; + input.precision = 3; + input.step = .05; + } + button(settings, "simulation-close", "Close", [this] { simulation_open = false; }); auto& recover = ui.root().add(Kind::Panel, "recovery-panel"); recover.layout.absolute = true; recover.layout.width = 470; @@ -545,6 +683,79 @@ struct EditorUI::Impl { recover.layout.padding = 12; recover.layout.gap = 5; recover.visible = false; + auto& switching = ui.root().add(Kind::Panel, "project-switch-dialog"); + switching.layout.absolute = true; + switching.layout.width = 500; + switching.layout.height = 220; + switching.layout.padding = 16; + switching.layout.gap = 8; + switching.visible = false; + label(switching, "project-switch-title", "Switch project?"); + label(switching, "project-switch-dirty", "Unsaved changes remain in recovery journals."); + label(switching, "project-switch-jobs", "Active jobs and the Player stop on switching."); + label(switching, "project-switch-save", "Cancel to save your scenes first."); + auto& actions = switching.add(Kind::Row, "project-switch-actions"); + actions.layout.height = 32; + button( + actions, "project-switch-continue", "Switch project", + [this] { + project_switch_warning = false; + project_switch_requested = project_switch_enabled; + }, + 180); + button( + actions, "project-switch-cancel", "Cancel", [this] { project_switch_warning = false; }, + 90); + auto& project = ui.root().add(Kind::Panel, "project-settings-panel"); + project.layout.absolute = true; + project.layout.width = 610; + project.layout.height = 550; + project.layout.padding = 16; + project.layout.gap = 6; + project.layout.scroll = true; + project.visible = false; + auto& project_title = + project.add(Kind::Label, "project-settings-title", "Project settings"); + project_title.font_size = 20; + project_title.layout.height = 36; + label(project, "project-settings-name-label", "Project name"); + project.add(Kind::TextField, "project-settings-name"); + label(project, "project-settings-dimension-label", "Initial scene type"); + auto& dimensions = project.add(Kind::Row, "project-settings-dimensions"); + dimensions.layout.height = 30; + for (const auto value : {2, 3}) { + auto& choice = + dimensions.add(Kind::Tab, "project-settings-" + std::to_string(value) + "d", + std::to_string(value) + "D"); + choice.layout.width = 110; + choice.on_click = [this, value](Widget&) { project_settings_dimension = value; }; + } + label(project, "project-settings-start-label", "Start scene (saved project-relative path)"); + project.add(Kind::TextField, "project-settings-start"); + auto& scenes = project.add(Kind::Column, "project-settings-scenes"); + scenes.layout.height = 104; + scenes.layout.scroll = true; + scenes.layout.gap = 1; + label(project, "project-settings-note", + "Applies on next project open. Scene Undo is unchanged."); + auto& project_actions = project.add(Kind::Row, "project-settings-actions"); + project_actions.layout.height = 32; + button( + project_actions, "project-settings-save", "Save project", + [this] { save_project_settings(); }, 145) + .selected = true; + button( + project_actions, "project-settings-reload", "Reload saved", + [this] { open_project_settings(); }, 130); + button( + project_actions, "project-settings-cancel", "Cancel", + [this] { + project_settings_open = false; + ui.clear_focus(false); + }, + 85); + label(project, "project-settings-error", ""); + label(project, "project-settings-reload-note", "Reload saved discards this form's edits."); } void new_scene(int dimension) { auto result = @@ -553,7 +764,10 @@ struct EditorUI::Impl { choose_document(result.at("id")); menu.clear(); } - void choose_document(const std::string& id) { + void choose_document(std::string id) { + if (document != id) + last_document = document; + instance_selection = Json::array(); ui.clear_focus(false); document = id; selected.clear(); @@ -563,17 +777,285 @@ struct EditorUI::Impl { edit_revisions.clear(); gizmo_axis = -1; } - void delete_selected() { - if (selected.empty()) + bool inherited(const Json& object) const { + return object.contains("origin") && + !object.at("origin").value("path", Json::array()).empty(); + } + bool owned_addition(const Json& object) const { + return inherited(object) && object.at("origin").value("local", false) && + object.at("origin").at("path").size() == 1; + } + Json addition_record(const Json& object) const { + for (const auto& instance : current.at("scene").value("instances", Json::array())) + if (instance.at("id") == object.at("origin").at("path").front()) + for (const auto& item : instance.value("additions", Json::array())) + if (item.at("id") == object.at("origin").at("object")) + return item; + throw std::runtime_error("Instance-local addition is unavailable"); + } + void update_addition(const Json& object, Json addition, + std::uint64_t revision = std::numeric_limits::max()) { + transaction(Json::array({{{"op", "template.addition_set"}, + {"instance", object.at("origin").at("path").front()}, + {"value", std::move(addition)}}}), + revision); + } + void add_component(const std::string& type) { + const auto* object = entity(resolved, selected); + if (!object) return; - if (transaction(Json::array({{{"op", "entity.delete"}, {"entity", selected}}}))) + if (owned_addition(*object)) { + auto addition = addition_record(*object); + addition["components"].push_back( + {{"id", new_id()}, + {"type", type}, + {"version", session.authoring().schemas().schema(type).value("version", 1)}, + {"fields", session.authoring().schemas().default_fields(type)}}); + update_addition(*object, std::move(addition)); + component_menu = false; + } else if (!inherited(*object) && + transaction(Json::array( + {{{"op", "component.add"}, {"entity", selected}, {"type", type}}}))) + component_menu = false; + } + void remove_component(const std::string& cid) { + const auto* object = entity(resolved, selected); + if (!object) + return; + if (owned_addition(*object)) { + std::string source; + for (const auto& c : object->at("components")) + if (c.at("id") == cid) + source = c.at("source_id"); + auto addition = addition_record(*object); + auto& components = addition["components"]; + components.erase(std::remove_if(components.begin(), components.end(), + [&](const Json& c) { return c.at("id") == source; }), + components.end()); + update_addition(*object, std::move(addition)); + } else if (!inherited(*object)) + transaction(Json::array( + {{{"op", "component.remove"}, {"entity", selected}, {"component", cid}}})); + } + Json relative_address(const Json& object, const std::string& component_id = {}, + const std::string& field = {}) const { + const auto& origin = object.at("origin"); + auto path = origin.at("path"); + path.erase(path.begin()); + Json address = {{"path", path}, {"object", origin.at("object")}}; + if (!component_id.empty()) { + for (const auto& c : object.at("components")) + if (c.at("id") == component_id) { + address["component"] = c.value("source_id", component_id); + break; + } + address["field"] = field; + } + return address; + } + Json field_operation(const Json& object, const std::string& component_id, + const std::string& field, Json value) const { + if (inherited(object)) + return {{"op", "template.override"}, + {"instance", object.at("origin").at("path").front()}, + {"address", relative_address(object, component_id, field)}, + {"value", std::move(value)}}; + return {{"op", "component.set"}, + {"entity", object.at("id")}, + {"component", component_id}, + {"field", field}, + {"value", std::move(value)}}; + } + bool overridden(const Json& object, const std::string& component_id, + const std::string& field) const { + if (!inherited(object)) + return false; + const auto address = relative_address(object, component_id, field); + for (const auto& instance : current.at("scene").value("instances", Json::array())) + if (instance.at("id") == object.at("origin").at("path").front()) + for (const auto& change : instance.value("overrides", Json::array())) + if (change.at("address") == address) + return true; + return false; + } + std::string instance_source(const Json& path) const { + auto source_scene = current.at("scene"); + std::string source; + for (const auto& id : path) { + const auto instances = source_scene.value("instances", Json::array()); + auto found = std::find_if(instances.begin(), instances.end(), + [&](const Json& item) { return item.at("id") == id; }); + if (found == instances.end()) + return {}; + source = found->at("source"); + bool open = false; + for (const auto& doc : session.authoring().documents()) + if (doc.value("path", std::string()) == source) { + source_scene = session.authoring().query(doc.at("id"))["scene"]; + open = true; + break; + } + if (!open) + try { + source_scene = read_json(project_path(session.config().project_root, source)); + } catch (...) { + return source; + } + } + return source; + } + void open_template_source(const Json& path, const std::string& object = {}) { + const auto source = instance_source(path); + if (source.empty()) + return; + const auto opened = call("faset_document_open", {{"path", source}}); + if (!opened.is_null()) { + choose_document(opened.at("id")); + selected = object; + } + } + void select_instance(const Json& path) { + ui.clear_focus(); + selected.clear(); + instance_selection = path; + preview_fields.clear(); + edit_revisions.clear(); + } + void delete_selected() { + if (!instance_selection.empty()) { + if (instance_selection.size() != 1) { + report("Open the source scene to remove this nested instance"); + return; + } + if (transaction(Json::array( + {{{"op", "template.remove"}, {"instance", instance_selection.front()}}}))) + instance_selection = Json::array(); + return; + } + const auto* object = entity(resolved, selected); + if (!object) + return; + const Json operation = inherited(*object) + ? Json{{"op", "template.suppress"}, + {"instance", object->at("origin").at("path").front()}, + {"value", relative_address(*object)}} + : Json{{"op", "entity.delete"}, {"entity", selected}}; + if (transaction(Json::array({operation}))) selected.clear(); } + void reparent(const std::string& id, const std::string& parent) { + const auto* object = entity(resolved, id); + const auto* target = parent.empty() ? nullptr : entity(resolved, parent); + if (!object) + return; + if (inherited(*object)) { + const auto path = object->at("origin").at("path"); + if (target && (!inherited(*target) || target->at("origin").at("path") != path)) { + report("Instance reparenting stays within the same instance path"); + return; + } + transaction( + Json::array({{{"op", "template.reparent"}, + {"instance", path.front()}, + {"value", + {{"object", relative_address(*object)}, + {"parent", target ? relative_address(*target) : Json(nullptr)}, + {"keep_world", true}}}}})); + } else { + if (target && inherited(*target)) { + report("Add a local child through the instance Inspector"); + return; + } + transaction(Json::array({{{"op", "entity.reparent"}, + {"entity", id}, + {"parent", parent.empty() ? Json(nullptr) : Json(parent)}, + {"keep_world", true}}})); + } + } + void add_instance_child(const Json& path, const std::string& parent = {}) { + if (path.size() != 1) { + report("Open the nested source to add a child there"); + return; + } + auto object = authoring::make_entity(session.authoring().schemas(), "Local Object", parent); + transaction( + Json::array({{{"op", "template.add"}, {"instance", path.front()}, {"value", object}}})); + } + void instance_scene(const std::string& path) { + try { + auto data = read_json(project_path(session.config().project_root, path)); + authoring::validate_scene(data, session.authoring().schemas()); + if (data.at("id") == document) + throw std::runtime_error("A scene cannot instance itself"); + const auto id = new_id(); + if (transaction(Json::array({{{"op", "template.instance"}, + {"instance", + {{"id", id}, + {"source", path}, + {"overrides", Json::array()}, + {"suppressed", Json::array()}, + {"additions", Json::array()}, + {"reparents", Json::array()}}}}}))) + select_instance(Json::array({id})); + } catch (const std::exception& e) { + report(e.what()); + } + } + void save_selection_template(const std::string& path) { + const auto* object = entity(resolved, selected); + if (!object) { + report("Select an object subtree to create a template"); + return; + } + try { + std::set subtree{selected}; + bool changed = true; + while (changed) { + changed = false; + for (const auto& item : resolved.at("entities")) + if (item.at("parent").is_string() && + subtree.contains(item.at("parent").get())) + changed = + subtree.insert(item.at("id").get()).second || changed; + } + auto result = call("faset_document_create", + {{"name", std::filesystem::path(path).stem().string()}, + {"dimension", current.at("scene").value("dimension", 3)}}); + if (result.is_null()) + return; + Json operations = Json::array(); + for (auto item : resolved.at("entities")) + if (subtree.contains(item.at("id").get())) { + item.erase("origin"); + for (auto& c : item["components"]) + c.erase("source_id"); + if (item.at("id") == selected) + item["parent"] = nullptr; + operations.push_back({{"op", "entity.create"}, {"entity", item}}); + } + const auto id = result.at("id"); + result = call("faset_scene_edit", {{"document", id}, + {"revision", result.at("revision")}, + {"operations", operations}}); + if (result.is_null()) + return; + result = call("faset_document_save", {{"document", id}, {"path", path}}); + if (!result.is_null()) { + status = "Saved template: " + path; + source_file = path; + assets_dirty = true; + menu.clear(); + } + } catch (const std::exception& e) { + report(e.what()); + } + } void select(const std::string& id) { - if (selected == id) + if (selected == id && instance_selection.empty()) return; ui.clear_focus(); selected = id; + instance_selection = Json::array(); preview_fields.clear(); edit_revisions.clear(); component_menu = false; @@ -618,22 +1100,31 @@ struct EditorUI::Impl { {"version", 1}, {"fields", {{"asset", id}, {"primitive", "asset"}, {"color", {1, 1, 1, 1}}}}}); if (transaction(Json::array({{{"op", "entity.create"}, {"entity", object}}}))) - selected = entity_id; + select(entity_id); } void refresh() { if (document.empty()) return; current = session.authoring().query(document); schemas = session.authoring().schemas().manifest(); - if (shown_revision != current.at("revision").get()) { + const auto document_stamp = session.authoring().documents().dump(); + if (shown_revision != current.at("revision").get() || + resolved_stamp != document_stamp) { + resolved_stamp = document_stamp; auto result = call("faset_template_preview", {{"document", document}}); resolved = result.is_null() ? current.at("scene") : result.at("scene"); + template_conflicts = result.is_null() ? Json::array() : result.at("conflicts"); + for (auto& object : resolved["entities"]) + if (!object.contains("origin")) + object["origin"] = {{"path", Json::array()}, {"object", object.at("id")}}; if (!result.is_null() && !result.at("conflicts").empty()) status = "Template conflicts: " + std::to_string(result.at("conflicts").size()); shown_revision = current.at("revision"); } - if (!selected.empty() && !entity(current.at("scene"), selected)) + if (!selected.empty() && !entity(resolved, selected)) selected.clear(); + if (!instance_selection.empty() && instance_source(instance_selection).empty()) + instance_selection = Json::array(); refresh_tree(); refresh_inspector(); refresh_assets(); @@ -644,60 +1135,108 @@ struct EditorUI::Impl { ui.find("pause")->enabled = session.playing(); ui.find("step")->enabled = session.playing(); ui.find("pause")->text = paused ? "Resume" : "Pause"; + const auto schema_state = call("faset_schema_status"); + if (!schema_state.is_null()) { + const bool stale = schema_state.value("stale", false); + ui.find("build")->text = stale ? "Build C++ !" : "Build C++"; + ui.find("build")->tooltip = + stale ? "Gameplay schema is stale: " + schema_state.value("error", std::string()) + : "Build gameplay and refresh metadata"; + if (stale && status == "Ready") + status = "Gameplay schema is stale; Build C++ to refresh"; + } + ui.find("project-title")->text = session.project().value("name", std::string("Project")) + " / " + current.at("name").get() + (current.value("dirty", false) ? " *" : ""); ui.find("status")->text = status; ui.find("renderer-status")->text = - "Vulkan 1.3 | " + std::to_string(current.at("scene").at("entities").size()) + - " objects"; + "Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects"; } void refresh_tree() { auto& tree = *ui.find("scene-tree"); std::set keep; - const auto& scene = current.at("scene"); - auto& root = tree.add(Kind::TreeRow, "scene-root", scene.at("name")); - root.selected = selected.empty(); + std::map order; + std::size_t sequence = 0; + auto touch = [&](Widget& row) { + keep.insert(row.id); + order[row.id] = sequence++; + }; + auto& root = tree.add(Kind::TreeRow, "scene-root", current.at("scene").at("name")); + root.selected = selected.empty() && instance_selection.empty(); root.on_click = [this](Widget&) { select(""); }; - keep.insert(root.id); - std::function append = [&](std::string parent, int depth) { - for (const auto& e : scene.at("entities")) { - const auto p = e.at("parent").is_string() ? e.at("parent").get() : ""; - if (p != parent) + touch(root); + std::vector groups; + for (const auto& instance : current.at("scene").value("instances", Json::array())) + groups.push_back(Json::array({instance.at("id")})); + for (const auto& object : resolved.at("entities")) { + const auto path = object.at("origin").at("path"); + Json prefix = Json::array(); + for (const auto& part : path) { + prefix.push_back(part); + if (std::find(groups.begin(), groups.end(), prefix) == groups.end()) + groups.push_back(prefix); + } + } + for (const auto& conflict : template_conflicts) { + Json prefix = Json::array(); + for (const auto& part : conflict.at("instance_path")) { + prefix.push_back(part); + if (std::find(groups.begin(), groups.end(), prefix) == groups.end()) + groups.push_back(prefix); + } + } + std::function objects = [&](const Json& path, + std::string parent, + int depth) { + for (const auto& object : resolved.at("entities")) { + if (object.at("origin").at("path") != path) continue; - const auto id = e.at("id").get(); - auto& row = tree.add(Kind::TreeRow, "entity-" + id, e.at("name")); - keep.insert(row.id); + const auto parent_id = + object.at("parent").is_string() ? object.at("parent").get() : ""; + const auto* parent_object = + parent_id.empty() ? nullptr : entity(resolved, parent_id); + const bool own_parent = + parent_object && parent_object->at("origin").at("path") == path; + if ((own_parent ? parent_id : "") != parent) + continue; + const auto id = object.at("id").get(); + auto& row = tree.add(Kind::TreeRow, "entity-" + id, object.at("name")); row.indent = depth; row.selected = selected == id; - row.drag_payload = {{"kind", "entity"}, {"id", id}, {"label", e.at("name")}}; + row.drag_payload = {{"kind", "entity"}, {"id", id}, {"label", object.at("name")}}; row.on_click = [this, id](Widget&) { select(id); }; row.on_drop = [this, id](Widget&, const Json& payload) { if (payload.value("kind", std::string()) == "entity") - transaction(Json::array({{{"op", "entity.reparent"}, - {"entity", payload.at("id")}, - {"parent", id}, - {"keep_world", true}}})); + reparent(payload.at("id"), id); }; - append(id, depth + 1); + touch(row); + objects(path, id, depth + 1); } }; - append("", 1); - trim_children(tree, keep); - // Match retained children to hierarchy order after a reparent without - // changing widget IDs. - std::map order; - std::size_t n = 0; - std::function visit = [&](std::string p) { - for (const auto& e : scene.at("entities")) - if ((e.at("parent").is_string() ? e.at("parent").get() : "") == p) { - const auto id = e.at("id").get(); - order["entity-" + id] = ++n; - visit(id); - } + objects(Json::array(), "", 1); + std::function append_group = [&](Json path, int depth) { + const auto source = instance_source(path); + auto& row = tree.add(Kind::TreeRow, "instance-" + path.dump(), + "[T] " + std::filesystem::path(source).filename().string()); + row.indent = depth; + row.selected = instance_selection == path; + row.on_click = [this, path](Widget&) { select_instance(path); }; + touch(row); + objects(path, "", depth + 1); + for (const auto& candidate : groups) { + if (candidate.size() != path.size() + 1) + continue; + auto prefix = candidate; + prefix.erase(prefix.end() - 1); + if (prefix == path) + append_group(candidate, depth + 1); + } }; - order["scene-root"] = 0; - visit(""); + for (const auto& group : groups) + if (group.size() == 1) + append_group(group, 1); + trim_children(tree, keep); std::stable_sort(tree.children.begin(), tree.children.end(), [&](const auto& a, const auto& b) { return order[a->id] < order[b->id]; }); } @@ -707,12 +1246,10 @@ struct EditorUI::Impl { const auto revision = found == edit_revisions.end() ? current.at("revision").get() : found->second; - transaction(Json::array({{{"op", "component.set"}, - {"entity", selected}, - {"component", component_id}, - {"field", field}, - {"value", std::move(value)}}}), - revision); + if (const auto* object = entity(resolved, selected)) + transaction( + Json::array({field_operation(*object, component_id, field, std::move(value))}), + revision); edit_revisions.erase(widget); preview_fields.erase(component_id + "/" + field); } @@ -769,10 +1306,61 @@ struct EditorUI::Impl { set_field(cid, field, payload.at("id"), id); }; } + void refresh_instance_inspector(Widget& body, std::set& keep) { + const auto path = instance_selection; + const auto source = instance_source(path); + label(body, "instance-heading", "Scene instance"); + keep.insert("instance-heading"); + auto& field = body.add(Kind::TextField, "instance-source"); + ui.update_text(field.id, source); + field.enabled = path.size() == 1; + field.on_commit = [this, path](Widget& w) { + transaction(Json::array( + {{{"op", "template.source_set"}, {"instance", path.front()}, {"source", w.text}}})); + }; + keep.insert(field.id); + button(body, "instance-open-source", "Open source", + [this, path] { open_template_source(path); }); + keep.insert("instance-open-source"); + auto& remove = + button(body, "instance-remove", "Remove instance", [this] { delete_selected(); }); + remove.enabled = path.size() == 1; + keep.insert(remove.id); + auto& add = button(body, "instance-add-local", "Add local object", + [this, path] { add_instance_child(path); }); + add.enabled = path.size() == 1; + keep.insert(add.id); + if (path.size() != 1) { + label(body, "instance-nested-note", "Open source for nested structural changes"); + keep.insert("instance-nested-note"); + return; + } + for (const auto& instance : current.at("scene").value("instances", Json::array())) + if (instance.at("id") == path.front()) { + std::size_t i = 0; + for (const auto& address : instance.value("suppressed", Json::array())) { + const auto id = "instance-restore-" + std::to_string(i++); + button(body, id, + "Restore suppressed object " + + address.at("object").get().substr(0, 8), + [this, path, address] { + transaction(Json::array({{{"op", "template.restore"}, + {"instance", path.front()}, + {"address", address}}})); + }); + keep.insert(id); + } + } + } void refresh_inspector() { auto& body = *ui.find("properties"); std::set keep; - const auto* e = entity(current.at("scene"), selected); + const auto* e = entity(resolved, selected); + if (!instance_selection.empty()) { + refresh_instance_inspector(body, keep); + trim_children(body, keep); + return; + } if (!e) { label(body, "inspector-empty", "Select an object to edit its components"); keep.insert("inspector-empty"); @@ -781,24 +1369,53 @@ struct EditorUI::Impl { } auto& name = body.add(Kind::TextField, "object-name"); ui.update_text(name.id, e->at("name")); + const bool source_object = inherited(*e), local_addition = owned_addition(*e); + name.enabled = !source_object || local_addition; + if (local_addition) { + label(body, "object-origin", "Local addition to this instance"); + keep.insert("object-origin"); + } else if (source_object) { + const auto path = e->at("origin").at("path"); + const auto object = e->at("origin").at("object").get(); + label(body, "object-origin", + "Source: " + std::filesystem::path(instance_source(path)).filename().string()); + keep.insert("object-origin"); + button(body, "object-open-source", "Open source", + [this, path, object] { open_template_source(path, object); }); + keep.insert("object-open-source"); + } keep.insert(name.id); name.on_preview = [this](Widget&) { edit_revisions.try_emplace("object-name", current.at("revision").get()); }; name.on_cancel = [this](Widget& w) { edit_revisions.erase(w.id); }; name.on_commit = [this](Widget& w) { - transaction( - Json::array({{{"op", "entity.rename"}, {"entity", selected}, {"name", w.text}}}), - edit_revisions.contains(w.id) ? edit_revisions[w.id] - : current.at("revision").get()); + const auto revision = edit_revisions.contains(w.id) + ? edit_revisions[w.id] + : current.at("revision").get(); + if (const auto* object = entity(resolved, selected)) { + if (owned_addition(*object)) { + auto addition = addition_record(*object); + addition["name"] = w.text; + update_addition(*object, std::move(addition), revision); + } else + transaction( + Json::array( + {{{"op", "entity.rename"}, {"entity", selected}, {"name", w.text}}}), + revision); + } edit_revisions.erase(w.id); }; for (const auto& c : e->at("components")) { const auto cid = c.at("id").get(), type = c.at("type").get(); - const bool known = session.authoring().schemas().contains(type); - const auto metadata = - known ? session.authoring().schemas().schema(type) - : Json{{"name", type + " (schema missing)"}, {"fields", Json::object()}}; + const bool has_schema = session.authoring().schemas().contains(type); + const bool known = + has_schema && c.value("version", 1) == + session.authoring().schemas().schema(type).value("version", 1); + const auto metadata = known ? session.authoring().schemas().schema(type) + : Json{{"name", type + (has_schema ? " (version mismatch)" + : " (schema missing)")}, + {"fields", Json::object()}}; auto& header = body.add(Kind::Row, "component-header-" + cid); keep.insert(header.id); header.layout.height = 28; @@ -806,12 +1423,20 @@ struct EditorUI::Impl { header.add(Kind::Label, "component-title-" + cid, metadata.value("name", type)); title.layout.flex = 1; button( - header, "component-remove-" + cid, "x", - [this, cid] { - transaction(Json::array( - {{{"op", "component.remove"}, {"entity", selected}, {"component", cid}}})); - }, - 25); + header, "component-remove-" + cid, "x", [this, cid] { remove_component(cid); }, 25) + .enabled = !source_object || local_addition; + if (!known) { + label(body, "opaque-note-" + cid, "Schema unavailable. Data is preserved."); + keep.insert("opaque-note-" + cid); + auto& raw = + body.add(Kind::TextField, "opaque-fields-" + cid, c.at("fields").dump()); + raw.enabled = false; + keep.insert(raw.id); + button(body, "opaque-copy-" + cid, "Copy raw fields", + [this, fields = c.at("fields")] { renderer.set_clipboard(fields.dump(2)); }); + keep.insert("opaque-copy-" + cid); + continue; + } for (const auto& [fid, value] : c.at("fields").items()) { const auto descriptor = metadata.at("fields").value(fid, Json::object()); const auto key = "field-" + cid + "-" + fid; @@ -837,7 +1462,23 @@ struct EditorUI::Impl { ui.clear_focus(false); row.remove(input_id); } - trim_children(row, {key + "-label", input_id}); + if (source_object) { + const auto address = relative_address(*e, cid, fid); + const auto instance = e->at("origin").at("path").front(); + const bool local_override = overridden(*e, cid, fid); + row.find(key + "-label")->text += local_override ? " · Override" : " · Source"; + auto& revert = + button(row, key + "-revert", "Revert", [this, instance, address] { + transaction(Json::array({{{"op", "template.revert"}, + {"instance", instance}, + {"address", address}}})); + }); + revert.enabled = local_override; + revert.layout.height = 23; + } + trim_children(row, source_object ? std::set{key + "-label", input_id, + key + "-revert"} + : std::set{key + "-label", input_id}); if (!options.empty()) { auto& input = button(row, key + "-value", @@ -858,6 +1499,7 @@ struct EditorUI::Impl { } else if (vector_value) { auto& vectorrow = row.add(Kind::Row, key + "-vector"); vectorrow.layout.gap = 3; + std::set axis_ids; for (std::size_t axis = 0; axis < value.size(); ++axis) { auto& input = vectorrow.add(Kind::NumberField, key + "-" + std::to_string(axis)); @@ -866,7 +1508,9 @@ struct EditorUI::Impl { ui.update_number(input.id, value[axis].get()); input.step = .02; bind_field(input, cid, fid, value, int(axis)); + axis_ids.insert(input.id); } + trim_children(vectorrow, axis_ids); } else if (value.is_number()) { auto& input = row.add(Kind::NumberField, key + "-value"); input.step = value.is_number_integer() ? 1 : .05; @@ -883,16 +1527,21 @@ struct EditorUI::Impl { } auto& add = button(body, "add-component", "+ Add Component", [this] { component_menu = !component_menu; }); + add.enabled = !source_object || local_addition; keep.insert(add.id); - if (component_menu) + if (source_object) { + const auto path = e->at("origin").at("path"); + const auto object = e->at("origin").at("object").get(); + auto& child = button(body, "instance-add-child", "Add local child", + [this, path, object] { add_instance_child(path, object); }); + child.enabled = path.size() == 1; + keep.insert(child.id); + } + if (component_menu && (!source_object || local_addition)) for (const auto& schema : schemas.at("types")) { const auto type = schema.at("id").get(); - auto& choice = button( - body, "component-choice-" + type, schema.value("name", type), [this, type] { - if (transaction(Json::array( - {{{"op", "component.add"}, {"entity", selected}, {"type", type}}}))) - component_menu = false; - }); + auto& choice = button(body, "component-choice-" + type, schema.value("name", type), + [this, type] { add_component(type); }); keep.insert(choice.id); } trim_children(body, keep); @@ -1018,12 +1667,61 @@ struct EditorUI::Impl { return std::find(order.begin(), order.end(), a->dock_panel) < std::find(order.begin(), order.end(), b->dock_panel); }); - for (const std::string id : {"assets", "console", "jobs"}) + for (const std::string id : {"assets", "console", "jobs", "conflicts"}) ui.find("tab-" + id)->selected = active_bottom == id; ui.find("asset-toolbar")->visible = active_bottom == "assets"; ui.find("asset-items")->visible = active_bottom == "assets"; ui.find("console-items")->visible = active_bottom == "console"; ui.find("job-items")->visible = active_bottom == "jobs"; + ui.find("conflict-items")->visible = active_bottom == "conflicts"; + ui.find("tab-conflicts")->text = + "Conflicts (" + std::to_string(template_conflicts.size()) + ")"; + auto& conflict_list = *ui.find("conflict-items"); + std::set conflict_keep; + std::size_t conflict_index = 0; + for (const auto& conflict : template_conflicts) { + const auto id = "conflict-" + std::to_string(conflict_index++); + auto& row = conflict_list.add(Kind::Row, id); + row.layout.height = 28; + const auto code = conflict.at("code").get(); + const auto path = conflict.at("instance_path"); + auto& text = row.add( + Kind::Label, id + "-text", + code + " · " + std::filesystem::path(instance_source(path)).filename().string()); + text.layout.flex = 1; + button( + row, id + "-source", "Open source", [this, path] { open_template_source(path); }, + 106); + const auto record = conflict.at("record"); + const auto change = record.contains("change") ? record.at("change") : record; + if (path.size() == 1 && code.starts_with("override.") && change.contains("address")) { + const auto address = change.at("address"); + button( + row, id + "-discard", "Discard override", + [this, path, address] { + transaction(Json::array({{{"op", "template.revert"}, + {"instance", path.front()}, + {"address", address}}})); + }, + 140); + } else if (path.size() == 1 && code == "suppression.object_missing") { + button( + row, id + "-discard", "Discard suppression", + [this, path, record] { + transaction(Json::array({{{"op", "template.restore"}, + {"instance", path.front()}, + {"address", record}}})); + }, + 155); + } + conflict_keep.insert(id); + } + if (conflict_keep.empty()) { + label(conflict_list, "conflicts-empty", + "No template conflicts. Missing targets preserve their overrides here."); + conflict_keep.insert("conflicts-empty"); + } + trim_children(conflict_list, conflict_keep); auto& console = *ui.find("console-items"); std::set keep; const auto& logs = session.logs(); @@ -1072,17 +1770,33 @@ struct EditorUI::Impl { popup.visible = !menu.empty(); ui.find("menu-title")->text = menu; refresh_recovery(); + refresh_simulation(); + 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"}) + ui.find(id)->enabled = !modal; const bool file = menu == "File" || menu == "Faset", edit = menu == "Edit" || menu == "Scene", help = menu == "Help" || menu == "View"; - for (const auto* id : - {"new-3d", "new-2d", "open-path", "open-path-button", "save-path", "save-as-button"}) + for (const auto* id : {"new-3d", "new-2d", "project-settings", "open-project", "open-path", + "open-path-button", "save-path", "save-as-button"}) ui.find(id)->visible = file; + ui.find("open-project")->enabled = project_switch_enabled; + ui.find("open-project")->tooltip = + project_switch_enabled ? "Return to project launcher" + : "Project switching is unavailable while MCP is connected"; + auto* switching = ui.find("project-switch-dialog"); + switching->visible = project_switch_warning; + switching->layout.x = std::max(0.f, (float(renderer.width()) - 500) * .5f); + switching->layout.y = std::max(0.f, (float(renderer.height()) - 220) * .5f); for (const auto* id : {"menu-duplicate", "menu-delete"}) ui.find(id)->visible = edit; for (const auto* id : {"help-one", "help-two", "help-three"}) ui.find(id)->visible = help; - popup.layout.height = file ? 260 : edit ? 112 : 150; + for (const auto* id : {"template-path", "save-template", "instance-template"}) + ui.find(id)->visible = menu == "Scene"; + popup.layout.height = file ? 335 : menu == "Scene" ? 220 : edit ? 112 : 150; popup.layout.x = menu == "File" ? 70 : menu == "Edit" ? 126 : menu == "Scene" ? 182 @@ -1107,6 +1821,168 @@ struct EditorUI::Impl { } trim_children(list, keep); } + void open_project_settings() { + ui.clear_focus(false); + auto result = call("faset_project_settings_get"); + if (result.is_null()) + return; + project_settings_state = result; + const auto& settings = result.at("settings"); + project_settings_dimension = settings.value("dimension", 3); + 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); + project_settings_error.clear(); + project_settings_open = true; + menu.clear(); + auto& choices = *ui.find("project-settings-scenes"); + choices.children.clear(); + choices.scroll_y = 0; + std::set saved; + for (const auto& doc : session.authoring().documents()) { + const auto path = doc.value("path", std::string()); + if (!path.empty()) + saved.insert(path); + } + try { + for (const auto* folder : {"Scenes", "Assets"}) { + const auto directory = session.config().project_root / folder; + if (!std::filesystem::is_directory(directory)) + continue; + std::size_t visited = 0; + for (const auto& entry : std::filesystem::recursive_directory_iterator( + directory, std::filesystem::directory_options::skip_permission_denied)) { + if (++visited > 4000) + break; + if (entry.is_regular_file() && + entry.path().filename().string().ends_with(".scene.json")) + saved.insert( + std::filesystem::relative(entry.path(), session.config().project_root) + .generic_string()); + } + } + } catch (const std::exception& error) { + project_settings_error = error.what(); + } + std::size_t index = 0; + for (const auto& path : saved) { + try { + const auto scene = read_json(project_path(session.config().project_root, path)); + if (scene.value("format", "") != "faset.scene" || scene.value("version", 0) != 1) + continue; + } catch (...) { + continue; + } + auto& choice = + choices.add(Kind::TreeRow, "project-scene-choice-" + std::to_string(index++), path); + choice.layout.height = 27; + choice.on_click = [this, path](Widget&) { + ui.update_text("project-settings-start", path, true); + project_settings_error.clear(); + }; + } + if (index == 0) + choices.add(Kind::Label, "project-settings-no-scenes", + "Save a scene to choose it here."); + } + void save_project_settings() { + ui.clear_focus(); + if (ui.editing()) + return; + const auto name = ui.find("project-settings-name")->text; + const auto start = ui.find("project-settings-start")->text; + if (name.find_first_not_of(" \t\r\n") == std::string::npos) { + project_settings_error = "Enter a project name."; + return; + } + 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; + const auto result = + call("faset_project_settings_set", + {{"revision", project_settings_state.at("revision")}, {"settings", changes}}); + if (result.is_null()) { + project_settings_error = status; + return; + } + project_settings_state = result; + project_settings_open = false; + project_settings_error.clear(); + status = "Project settings saved for the next project open"; + } + void refresh_project_settings() { + auto* panel = ui.find("project-settings-panel"); + panel->visible = project_settings_open; + panel->layout.width = std::min(610.f, std::max(340.f, float(renderer.width()) - 40)); + panel->layout.height = std::min(550.f, std::max(300.f, float(renderer.height()) - 40)); + panel->layout.x = std::max(0.f, (float(renderer.width()) - panel->layout.width) * .5f); + panel->layout.y = std::max(0.f, (float(renderer.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-error")->text = project_settings_error; + for (auto& choice : ui.find("project-settings-scenes")->children) + if (choice->kind == Kind::TreeRow) + choice->selected = choice->text == ui.find("project-settings-start")->text; + } + void refresh_simulation() { + auto& panel = *ui.find("simulation-panel"); + panel.visible = simulation_open; + panel.layout.x = std::max(0.f, (float(renderer.width()) - 470) / 2); + panel.layout.y = 90; + if (!simulation_open) + return; + const auto response = call("faset_simulation_get", {{"document", document}}); + if (response.is_null()) + return; + const auto settings = response.at("settings"); + auto bind = [&, this](Widget& input, const std::string& field, int axis = -1) { + const auto id = input.id; + input.on_preview = [this, id](Widget&) { + edit_revisions.try_emplace(id, current.at("revision").get()); + }; + input.on_cancel = [this, id](Widget&) { edit_revisions.erase(id); }; + input.on_commit = [this, id, field, axis, settings](Widget& w) { + Json patch; + if (axis >= 0) { + auto gravity = settings.at("gravity"); + gravity[axis] = w.value; + patch = {{"gravity", gravity}}; + } else if (field == "tick-rate") { + if (w.value <= 0) { + report("Tick rate must be positive"); + edit_revisions.erase(id); + return; + } + patch = {{"fixed_delta", 1.0 / w.value}}; + } else + patch = {{field, std::int64_t(std::llround(w.value))}}; + const auto revision = edit_revisions.contains(id) + ? edit_revisions[id] + : current.at("revision").get(); + const auto result = + call("faset_simulation_set", + {{"document", document}, {"revision", revision}, {"settings", patch}}); + edit_revisions.erase(id); + if (!result.is_null()) { + current = result; + status = "Scene simulation updated for the next Play"; + } + }; + }; + for (const std::string field : {"tick-rate", "max_catch_up_ticks", "physics_substeps"}) { + auto& input = *ui.find("simulation-" + field); + ui.update_number(input.id, field == "tick-rate" + ? 1.0 / settings.at("fixed_delta").get() + : settings.at(field).get()); + bind(input, field); + } + for (int axis = 0; axis < 3; ++axis) { + auto& input = *ui.find("simulation-gravity-" + std::to_string(axis)); + ui.update_number(input.id, settings.at("gravity")[axis].get()); + bind(input, "gravity", axis); + } + } void refresh_recovery() { auto& panel = *ui.find("recovery-panel"); panel.visible = !recovery.empty(); @@ -1193,6 +2069,9 @@ struct EditorUI::Impl { } viewport = ui.find("viewport")->rect; rendered = view.build(scene, viewport.width / std::max(1.f, viewport.height), camera()); + for (const auto& diagnostic : view.diagnostics()) + if (seen_view_diagnostics.insert(diagnostic).second) + session.log("Viewport: " + diagnostic); rendered.scene_rect = {viewport.x, viewport.y, viewport.width, viewport.height}; rendered.clear_color = ui.theme().background; const auto key = scene.dump() + assets.dump(); @@ -1246,7 +2125,7 @@ struct EditorUI::Impl { render::DrawItem grid; grid.mesh = render::cube_mesh(); grid.cast_shadow = false; - grid.color = {.12f, .12f, .13f, 1}; + grid.color = {.035f, .035f, .04f, 1}; grid.model = render::transform({float(i), 0, 0}, {}, {.012f, .002f, 20}); rendered.draws.push_back(grid); grid.model = render::transform({0, 0, float(i)}, {}, {20, .002f, .012f}); @@ -1254,13 +2133,19 @@ struct EditorUI::Impl { } } gizmo_valid = false; - if (auto* e = entity(scene, selected)) { + if (auto* e = entity(scene, selected); + e && component(*e, "faset.transform") && + component(*e, "faset.transform")->value("version", 1) == 1) { gizmo_origin = point(world(scene, *e), {}); gizmo_valid = project(gizmo_origin, gizmo_screen[0]); const auto length = is2d ? ortho * .12f : distance * .12f; for (int axis = 0; axis < 3; ++axis) { Vec3 p = gizmo_origin; - p[axis] += length; + Vec3 direction{}; + direction[axis] = 1; + if (gizmo_mode != "Move") + direction = normalize(vector(world(scene, *e), direction)); + p = add(p, mul(direction, length)); gizmo_valid = project(p, gizmo_screen[axis + 1]) && gizmo_valid; } if (gizmo_valid) { @@ -1305,11 +2190,11 @@ struct EditorUI::Impl { bool start_gizmo(float x, float y) { if (!gizmo_valid) return false; - const auto* e = entity(current.at("scene"), selected); + const auto* e = entity(resolved, selected); if (!e) return false; const auto* c = component(*e, "faset.transform"); - if (!c) + if (!c || c->value("version", 1) != 1) return false; float best = 8; int axis = -1; @@ -1351,21 +2236,23 @@ struct EditorUI::Impl { delta[gizmo_axis] = amount * gizmo_world_length; if (snap) delta[gizmo_axis] = std::round(delta[gizmo_axis] * 4) / 4; - const auto* e = entity(current.at("scene"), selected); + const auto* e = entity(resolved, selected); if (e && e->at("parent").is_string()) - if (auto* parent = - entity(current.at("scene"), e->at("parent").get())) { + if (auto* parent = entity(resolved, e->at("parent").get())) { Mat4 inv; - if (inverse(world(current.at("scene"), *parent), inv)) + if (inverse(world(resolved, *parent), inv)) delta = vector(inv, delta); } auto position = vec(gizmo_original.at("position")); position = add(position, delta); preview_fields[gizmo_component + "/position"] = position; } else if (gizmo_mode == "Rotate") { - auto rotation = vec(gizmo_original.at("rotation")); - rotation[gizmo_axis] += snap ? std::round(amount * 12) * .261799f : amount * 3.141593f; - preview_fields[gizmo_component + "/rotation"] = rotation; + Vec3 delta{}; + delta[gizmo_axis] = snap ? std::round(amount * 12) * .261799f : amount * 3.141593f; + const auto composed = + render::multiply(render::transform({}, vec(gizmo_original.at("rotation"))), + render::transform({}, delta)); + preview_fields[gizmo_component + "/rotation"] = euler_xyz(composed); } else { auto scale = vec(gizmo_original.at("scale")); scale[gizmo_axis] *= std::max(.01f, 1 + amount); @@ -1432,12 +2319,10 @@ struct EditorUI::Impl { : "scale"; const auto key = gizmo_component + "/" + field; if (preview_fields.contains(key)) - transaction(Json::array({{{"op", "component.set"}, - {"entity", selected}, - {"component", gizmo_component}, - {"field", field}, - {"value", preview_fields[key]}}}), - gizmo_revision); + if (const auto* object = entity(resolved, selected)) + transaction(Json::array({field_operation(*object, gizmo_component, field, + preview_fields[key])}), + gizmo_revision); preview_fields.erase(key); gizmo_axis = -1; return true; @@ -1454,11 +2339,26 @@ struct EditorUI::Impl { using Type = render::Event::Type; for (const auto& event : input) { if (event.type == Type::KeyDown && event.key == "Escape") { + if (project_settings_open) { + project_settings_open = false; + ui.clear_focus(false); + continue; + } + if (project_switch_warning) { + project_switch_warning = false; + ui.clear_focus(false); + continue; + } if (gizmo_axis >= 0) { gizmo_axis = -1; preview_fields.clear(); continue; } + if (simulation_open) { + simulation_open = false; + ui.clear_focus(false); + continue; + } if (palette || !menu.empty()) { palette = false; menu.clear(); @@ -1473,8 +2373,14 @@ struct EditorUI::Impl { mouse_x = event.x; mouse_y = event.y; } - if ((palette || !recovery.empty()) && event.type == Type::MouseDown) { - auto* modal = ui.find(!recovery.empty() ? "recovery-panel" : "palette"); + if ((palette || !recovery.empty() || simulation_open || project_switch_warning || + project_settings_open) && + event.type == Type::MouseDown) { + auto* modal = ui.find(project_settings_open ? "project-settings-panel" + : project_switch_warning ? "project-switch-dialog" + : !recovery.empty() ? "recovery-panel" + : simulation_open ? "simulation-panel" + : "palette"); if (!modal->rect.contains(event.x, event.y)) continue; } @@ -1484,6 +2390,10 @@ struct EditorUI::Impl { } if (ui.handle(event)) continue; + if ((project_switch_warning || !recovery.empty() || simulation_open || + project_settings_open) && + event.type == Type::KeyDown) + continue; if (event.type == Type::KeyDown) { if (event.control && (event.key == "S" || event.key == "s")) { save(); @@ -1494,6 +2404,9 @@ struct EditorUI::Impl { continue; } if (event.control && (event.key == "P" || event.key == "p")) { + ui.clear_focus(); + if (ui.editing()) + continue; palette = !palette; continue; } @@ -1515,6 +2428,7 @@ struct EditorUI::Impl { } void frame(const std::vector& events_) { session.poll(); + poll_presentation(); refresh(); ui.layout(float(renderer.width()), float(renderer.height())); if (rendered.scene_rect[2] == 0) @@ -1550,4 +2464,14 @@ const std::string& EditorUI::selected_entity() const { void EditorUI::select_entity(const std::string& id) { impl_->select(id); } +bool EditorUI::project_switch_requested() const { + return impl_->project_switch_requested; +} +void EditorUI::set_project_switch_enabled(bool enabled) { + impl_->project_switch_enabled = enabled; + if (!enabled) { + impl_->project_switch_requested = false; + impl_->project_switch_warning = false; + } +} } // namespace faset::editor diff --git a/src/editor/mcp.cpp b/src/editor/mcp.cpp index 7f0e475..6cbf9ed 100644 --- a/src/editor/mcp.cpp +++ b/src/editor/mcp.cpp @@ -1,11 +1,13 @@ +#include #include -#include #ifdef _WIN32 #define NOMINMAX #include #else #include +#include #include +#include #include #endif @@ -126,7 +128,6 @@ std::vector StdioTransport::poll() { if (type == FILE_TYPE_PIPE) { if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) { closed_ = true; - return lines; } } else if (type == FILE_TYPE_DISK) available = sizeof(bytes); @@ -169,7 +170,51 @@ std::vector StdioTransport::poll() { return lines; } void StdioTransport::send(const Json& value) { - std::cout << value.dump() << '\n'; - std::cout.flush(); + const auto bytes = value.dump() + '\n'; + std::size_t offset = 0; +#ifdef _WIN32 + const auto output = GetStdHandle(STD_OUTPUT_HANDLE); + while (offset < bytes.size()) { + DWORD written = 0; + const auto count = static_cast(std::min(bytes.size() - offset, 65536)); + if (!WriteFile(output, bytes.data() + offset, count, &written, nullptr) || written == 0) { + closed_ = true; + return; + } + offset += written; + } +#else + // A disconnected MCP client must not terminate the GUI with SIGPIPE. Block + // it only on this thread during the write, preserving the process signal + // policy and any SIGPIPE that was already pending for the caller. + sigset_t blocked, previous, pending; + sigemptyset(&blocked); + sigaddset(&blocked, SIGPIPE); + if (pthread_sigmask(SIG_BLOCK, &blocked, &previous) != 0) { + closed_ = true; + return; + } + sigpending(&pending); + const bool alreadyPending = sigismember(&pending, SIGPIPE) == 1; + bool brokenPipe = false; + while (offset < bytes.size()) { + const auto written = ::write(STDOUT_FILENO, bytes.data() + offset, bytes.size() - offset); + if (written > 0) + offset += static_cast(written); + else if (written < 0 && errno == EINTR) + continue; + else { + brokenPipe = written < 0 && errno == EPIPE; + closed_ = true; + break; + } + } + if (brokenPipe && !alreadyPending) { + const timespec noWait{}; + while (sigtimedwait(&blocked, nullptr, &noWait) == -1 && errno == EINTR) { + } + } + pthread_sigmask(SIG_SETMASK, &previous, nullptr); +#endif } } // namespace faset::editor diff --git a/src/editor/plugins.cpp b/src/editor/plugins.cpp index 63e97f9..b8105c6 100644 --- a/src/editor/plugins.cpp +++ b/src/editor/plugins.cpp @@ -14,11 +14,25 @@ namespace faset::editor { namespace { -void append(void* context, const char* bytes, std::uint64_t size) { - auto& output = *static_cast(context); - require(size <= 8 * 1024 * 1024 && output.size() + size <= 8 * 1024 * 1024, - "plugin.output_limit", "Plugin response exceeds 8 MiB"); - output.append(bytes, static_cast(size)); +struct ResponseBuffer { + std::string bytes; + bool failed = false; +}; +void append(void* context, const char* bytes, std::uint64_t size) noexcept { + auto& output = *static_cast(context); + if (output.failed) + return; + if ((!bytes && size) || size > 8 * 1024 * 1024 || + output.bytes.size() + size > 8 * 1024 * 1024) { + output.failed = true; + return; + } + try { + if (size) + output.bytes.append(bytes, static_cast(size)); + } catch (...) { + output.failed = true; + } } Json response(const std::string& bytes) { auto value = Json::parse(bytes); @@ -237,11 +251,13 @@ struct PluginManager::Impl { commands.add( name, descriptor.at("description"), descriptor.at("inputSchema"), [registration](const Json& arguments) { - std::string output; + ResponseBuffer output; const auto input = arguments.dump(); const int result = registration.callback(registration.user, input.c_str(), append, &output); - const auto value = response(output); + require(!output.failed, "plugin.output_limit", + "Cannot collect plugin response (maximum 8 MiB)"); + const auto value = response(output.bytes); if (result != 0) throw Error(value.value("code", std::string("plugin.failed")), value.value("message", std::string("Plugin command failed")), diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 5cf1710..c6a5457 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -58,6 +58,10 @@ Session::Session(SessionConfig config) if (std::filesystem::exists(schema)) try { load_schema(schema); + const auto state = config_.project_root / ".faset/schema-state.json"; + if (std::filesystem::exists(state)) + schema_source_signature_ = + read_json(state).value("source_signature", std::string()); } catch (const std::exception& error) { log(std::string("Schema load failed: ") + error.what()); } @@ -77,8 +81,26 @@ void Session::log(std::string value) { } Json Session::project() const { const auto path = config_.project_root / "project.faset.json"; - if (std::filesystem::exists(path)) - return read_json(path); + 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().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(); + if (!scene.empty()) + project_path(config_.project_root, scene); + } + return value; + } return {{"format", "faset.project"}, {"version", 1}, {"name", config_.project_root.filename().string()}, @@ -105,6 +127,26 @@ Json Session::assets_list() const { } return {{"assets", list}}; } +std::string Session::source_signature() const { + const auto directory = config_.project_root / "Scripts"; + std::vector files; + if (std::filesystem::exists(directory)) + for (const auto& file : std::filesystem::recursive_directory_iterator(directory)) + if (file.is_regular_file()) + files.push_back(file.path()); + std::sort(files.begin(), files.end()); + std::string contents; + for (const auto& file : files) + contents += + file.lexically_relative(directory).generic_string() + ":" + sha256_file(file) + "\n"; + return sha256(contents); +} +Json Session::schema_status() const { + return {{"loaded", schema_loaded_}, + {"stale", !schema_loaded_ || schema_source_signature_ != source_signature() || + !schema_error_.empty()}, + {"error", schema_error_}}; +} Json Session::jobs() const { Json list = Json::array(); for (const auto& item : builds_.jobs()) @@ -124,6 +166,8 @@ void Session::load_schema(const std::filesystem::path& path) { const auto output = config_.project_root / ".faset/schema.json"; if (std::filesystem::weakly_canonical(path) != std::filesystem::weakly_canonical(output)) atomic_write_json(output, value); + schema_loaded_ = true; + schema_error_.clear(); log("Gameplay schema loaded"); } void Session::launch_player(Json scene, const std::filesystem::path& executable) { @@ -170,20 +214,35 @@ void Session::poll() { if (observed_jobs_[value.id] == value.state) continue; observed_jobs_[value.id] = value.state; - if (value.state == "failed") - log(value.kind + " failed: " + value.error); + bool schema_valid = true; + if (value.state == "failed") { + schema_error_ = value.error; + log(value.kind + " failed: " + value.error + + "; previous gameplay metadata remains available and is marked stale"); + } if (value.state == "succeeded") { log(value.kind + " completed"); if (value.result.contains("schema")) try { load_schema(value.result.at("schema").get()); + // This signature represents the sources submitted with this job, not later + // edits. + if (value.result.contains("source_signature")) + schema_source_signature_ = + value.result.at("source_signature").get(); + else if (submitted_sources_.contains(value.id)) + schema_source_signature_ = submitted_sources_.at(value.id); + atomic_write_json(config_.project_root / ".faset/schema-state.json", + {{"source_signature", schema_source_signature_}}); } catch (const std::exception& error) { + schema_valid = false; + schema_error_ = error.what(); log(std::string("Schema update failed: ") + error.what()); } } if (value.id == pending_play_job_ && value.finished()) { pending_play_job_.clear(); - if (value.state == "succeeded") + if (value.state == "succeeded" && schema_valid) try { const auto executable = value.result.value("player", (builds_.config().build_directory / @@ -242,67 +301,125 @@ void Session::register_commands() { commands_.add( "faset_project", "Read the authoring project's settings.", schema(Json::object()), [&](const Json&) { return project(); }, true); + commands_.add( + "faset_project_settings_get", "Read project settings and their content revision.", + schema(Json::object()), + [&](const Json&) { + const auto value = project(); + return Json{{"settings", value}, {"revision", sha256(value.dump())}}; + }, + true); + commands_.add( + "faset_project_settings_set", + "Save project name, initial scene dimension or start scene with an expected content " + "revision. Applies on the next project open; does not change the active scene or its Undo " + "history.", + schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}), + [&](const Json& args) { + 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", + "project.setting", "Unknown editable project setting: " + key); + if (key == "name") + require(field.is_string() && !field.get().empty(), "project.name", + "Project name must be a nonempty string"); + else if (key == "dimension") + require(field.is_number_integer() && (field == 2 || field == 3), + "project.dimension", "Initial scene dimension must be 2 or 3"); + else { + require(field.is_string() && !field.get().empty(), + "project.start_scene", "Choose a saved scene inside the project"); + const auto file = project_path(config_.project_root, field.get()); + require(std::filesystem::is_regular_file(file), "project.start_scene", + "Save the start scene before selecting it in Project settings"); + const auto scene = read_json(file); + require(scene.value("format", "") == "faset.scene" && + scene.value("version", 0) == 1, + "project.start_scene", + "The start scene must be a supported Faset scene"); + } + value[key] = field; + } + if (!value.contains("id")) + value["id"] = new_id(); + atomic_write_json(config_.project_root / "project.faset.json", value); + log("Project settings saved; changes apply on next project open"); + return Json{{"settings", value}, {"revision", sha256(value.dump())}}; + }); commands_.add( "faset_assets", "List imported asset manifests and resource identities.", schema(Json::object()), [&](const Json&) { return assets_list(); }, true); - commands_.add("faset_import", - "Import GLB/glTF or a Blender export manifest relative to this project. Returns " - "a cancellable job ID; failure retains the last successful generation.", - schema({{"path", text}, - {"settings", {{"type", "object"}}}, - {"allow_removed_outputs", boolean}}, - {"path"}), - [&](const Json& args) { - assets::ImportRequest request; - request.source = - project_path(config_.project_root, args.at("path").get()); - request.settings = args.value("settings", Json(nullptr)); - request.allow_removed_outputs = args.value("allow_removed_outputs", false); - auto task = std::make_shared(); - task->id = "import-" + new_id(); - imports_[task->id] = task; - workers_.emplace_back([this, task, request] { - { - std::lock_guard lock(task->mutex); - task->state = "running"; - } - try { - const auto result = assets_.import_asset(request, *task->job); - std::lock_guard lock(task->mutex); - task->state = - result.status == assets::ImportStatus::succeeded ? "succeeded" + commands_.add( + "faset_import", + "Import PNG/JPEG, GLB/glTF, or a Blender export manifest relative to this project. Returns " + "a cancellable job ID; failure retains the last successful generation.", + schema({{"path", text}, + {"settings", {{"type", "object"}}}, + {"allow_removed_outputs", boolean}}, + {"path"}), + [&](const Json& args) { + assets::ImportRequest request; + request.source = project_path(config_.project_root, args.at("path").get()); + request.settings = args.value("settings", Json(nullptr)); + request.allow_removed_outputs = args.value("allow_removed_outputs", false); + auto task = std::make_shared(); + task->id = "import-" + new_id(); + imports_[task->id] = task; + workers_.emplace_back([this, task, request] { + { + std::lock_guard lock(task->mutex); + task->state = "running"; + } + try { + const auto result = assets_.import_asset(request, *task->job); + std::lock_guard lock(task->mutex); + task->state = result.status == assets::ImportStatus::succeeded ? "succeeded" : result.status == assets::ImportStatus::cancelled ? "cancelled" : result.status == assets::ImportStatus::conflict ? "conflict" : "failed"; - task->result = {{"asset_id", result.asset_id}, - {"generation", result.generation}, - {"diagnostics", result.diagnostics}, - {"cache_hit", result.cache_hit}, - {"manifest", result.manifest}}; - for (const auto& message : result.diagnostics) - task->error += message + "\n"; - } catch (const std::exception& error) { - std::lock_guard lock(task->mutex); - task->state = "failed"; - task->error = error.what(); - } - }); - return Json{{"job", task->id}}; - }); + task->result = {{"asset_id", result.asset_id}, + {"generation", result.generation}, + {"diagnostics", result.diagnostics}, + {"cache_hit", result.cache_hit}, + {"manifest", result.manifest}}; + for (const auto& message : result.diagnostics) + task->error += message + "\n"; + } catch (const std::exception& error) { + std::lock_guard lock(task->mutex); + task->state = "failed"; + task->error = error.what(); + } + }); + return Json{{"job", task->id}}; + }); + commands_.add( + "faset_schema_status", + "Report whether the last successful gameplay schema matches current project scripts. " + "Failed builds retain metadata but mark it stale.", + schema(Json::object()), [&](const Json&) { return schema_status(); }, true); commands_.add("faset_build", "Incrementally compile C++ gameplay and export its metadata in separate native " "processes. Returns a job ID.", - schema(Json::object()), - [&](const Json&) { return Json{{"job", builds_.start_build()}}; }); + schema(Json::object()), [&](const Json&) { + const auto signature = source_signature(); + const auto id = builds_.start_build(); + submitted_sources_[id] = signature; + return Json{{"job", id}}; + }); commands_.add("faset_export", "Build, validate and export a resolved authoring snapshot to a project-relative " "output directory. Returns a job ID.", schema({{"document", text}, {"output", text}}, {"document", "output"}), [&](const Json& args) { - return Json{{"job", builds_.start_export( - resolved_or_throw(commands_, args.at("document")), - project_path(config_.project_root, - args.at("output").get()))}}; + const auto signature = source_signature(); + const auto id = builds_.start_export( + resolved_or_throw(commands_, args.at("document")), + project_path(config_.project_root, args.at("output").get())); + submitted_sources_[id] = signature; + return Json{{"job", id}}; }); commands_.add( "faset_jobs", "List editor import/build/export jobs and their progress.", @@ -326,7 +443,9 @@ void Session::register_commands() { schema({{"document", text}}, {"document"}), [&](const Json& args) { stop_player(); pending_play_scene_ = resolved_or_throw(commands_, args.at("document")); + const auto signature = source_signature(); pending_play_job_ = builds_.start_build(); + submitted_sources_[pending_play_job_] = signature; return Json{{"job", pending_play_job_}, {"play_pending", true}}; }); commands_.add( diff --git a/src/player/SceneView.cpp b/src/player/SceneView.cpp index 5f7d6a1..3d249d4 100644 --- a/src/player/SceneView.cpp +++ b/src/player/SceneView.cpp @@ -17,12 +17,19 @@ namespace faset::player { namespace { using Json = nlohmann::json; Json properties(const Json& entity, const std::string& name) { + if (entity.contains("components")) { + for (const auto& component : entity["components"]) + if (component.at("type") == "faset." + name) { + // Future authoring schemas remain opaque until an explicit migration. + if (component.value("version", 1) != 1) + return Json{}; + return component.at("fields"); + } + return Json{}; + } + // Runtime presentation snapshots already contain validated typed components. if (entity.contains(name)) return entity.at(name); - if (entity.contains("components")) - for (const auto& c : entity["components"]) - if (c.at("type") == "faset." + name) - return c.at("fields"); return Json{}; } template @@ -235,9 +242,21 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting if (!entities.is_array()) throw std::invalid_argument("Scene entities must be an array"); std::unordered_map byId; - for (const auto& entity : entities) - if (!byId.emplace(entity.at("id").get(), &entity).second) + for (const auto& entity : entities) { + const auto id = entity.at("id").get(); + if (!byId.emplace(id, &entity).second) throw std::invalid_argument("Duplicate scene ID"); + for (const auto& component : entity.value("components", Json::array())) { + const auto type = component.at("type").get(); + if (component.value("version", 1) != 1 && + (type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" || + type == "faset.camera" || type == "faset.light")) + impl_->messages.push_back("warning: preview skips unsupported " + type + + " version " + + std::to_string(component.at("version").get()) + + " on entity " + id + "; opaque data is preserved"); + } + } std::unordered_map matrices; std::set active; auto world = [&](auto&& self, const Json& entity) -> render::Mat4 { @@ -375,4 +394,77 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting impl_->messages.end()); return out; } +void SceneView::appendPhysicsDebug(render::Snapshot& snapshot, const Json& scene, + float thickness) const { + if (!std::isfinite(thickness) || thickness <= 0) + throw std::invalid_argument("Physics debug thickness must be positive"); + const int dimension = scene.at("dimension"); + if (dimension != 2 && dimension != 3) + throw std::invalid_argument("Physics debug dimension must be 2 or 3"); + static const auto edgeMesh = [] { + auto mesh = std::make_shared(*render::cube_mesh()); + for (auto& vertex : mesh->vertices) + vertex.normal = {0, 0, 0}; // Unlit debug color. + return mesh; + }(); + for (const auto& entity : scene.at("entities")) { + const auto body = properties(entity, dimension == 2 ? "rigid_body_2d" : "rigid_body_3d"); + if (body.is_null()) + continue; + if (entity.contains("parent") && !entity.at("parent").is_null()) + throw std::invalid_argument( + "Physics debug bodies must be roots, like the runtime adapters"); + const auto pose = properties(entity, "transform"); + const auto position = vec<3>(pose, "position", {0, 0, 0}); + const auto rotation = vec<3>(pose, "rotation", {0, 0, 0}); + const auto scale = vec<3>(pose, "scale", {1, 1, 1}); + if (dimension == 2 && (rotation[0] != 0 || rotation[1] != 0)) + throw std::invalid_argument("2D physics debug rotates only around Z"); + render::Vec3 half{}; + if (dimension == 2) { + const auto value = vec<2>(body, "half_extents", {.5f, .5f}); + half = {value[0], value[1], 0}; + } else + half = vec<3>(body, "half_extents", {.5f, .5f, .5f}); + for (int axis = 0; axis < dimension; ++axis) { + if (half[axis] <= 0 || std::abs(scale[axis]) <= .00001f) + throw std::invalid_argument("Invalid physics debug box extent/scale"); + half[axis] *= std::abs(scale[axis]); + if (!std::isfinite(half[axis])) + throw std::invalid_argument("Nonfinite physics debug box"); + } + const auto type = body.value("body_type", std::string("dynamic")); + const render::Color color = type == "static" ? render::Color{.2f, 1, .3f, 1} + : type == "kinematic" ? render::Color{1, .7f, .15f, 1} + : render::Color{.15f, .85f, 1, 1}; + const auto model = render::transform(position, rotation); + auto edge = [&](render::Vec3 center, render::Vec3 size) { + snapshot.draws.push_back({edgeMesh, + render::multiply(model, render::transform(center, {}, size)), + color, + .65f, + 0, + false, + {}}); + }; + if (dimension == 2) { + for (float side : {-1.0f, 1.0f}) { + edge({0, side * half[1], 0}, {2 * half[0], thickness, thickness}); + edge({side * half[0], 0, 0}, {thickness, 2 * half[1], thickness}); + } + } else { + for (int axis = 0; axis < 3; ++axis) { + const int first = (axis + 1) % 3, second = (axis + 2) % 3; + for (float a : {-1.0f, 1.0f}) + for (float b : {-1.0f, 1.0f}) { + render::Vec3 center{}, size{thickness, thickness, thickness}; + center[first] = a * half[first]; + center[second] = b * half[second]; + size[axis] = 2 * half[axis]; + edge(center, size); + } + } + } + } +} } // namespace faset::player diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index afb6e76..9c9a0d2 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -1,3 +1,4 @@ +#include "shader_contract.hpp" #include #include #include @@ -41,13 +42,14 @@ std::array point(const Mat4& m, std::array p) { struct Buffer { VkBuffer handle{}; VkDeviceMemory memory{}; - VkDeviceSize size{}; + VkDeviceSize size{}, allocation_size{}; }; struct Image { VkImage handle{}; VkDeviceMemory memory{}; VkImageView view{}; VkImageLayout layout{VK_IMAGE_LAYOUT_UNDEFINED}; + VkDeviceSize allocation_size{}; }; struct Batch { std::uint32_t first{}, count{}; @@ -74,6 +76,7 @@ struct Renderer::Impl { float timestamp_period{}; std::uint32_t timestamp_bits{}; VkSemaphore acquired{}, present_ready{}; + std::array shader_layouts{}; VkSwapchainKHR swapchain{}; VkFormat swap_format{}; VkExtent2D swap_extent{}; @@ -186,16 +189,22 @@ struct Renderer::Impl { if (sdl) SDL_QuitSubSystem(SDL_INIT_VIDEO); } - std::uint32_t memory_type(std::uint32_t bits, VkMemoryPropertyFlags properties) { + std::uint32_t memory_type(std::uint32_t bits, VkMemoryPropertyFlags properties, + VkMemoryPropertyFlags preferred = 0) { VkPhysicalDeviceMemoryProperties p{}; vkGetPhysicalDeviceMemoryProperties(physical, &p); + if (preferred) + for (std::uint32_t i = 0; i < p.memoryTypeCount; ++i) + if ((bits & (1u << i)) && (p.memoryTypes[i].propertyFlags & + (properties | preferred)) == (properties | preferred)) + return i; for (std::uint32_t i = 0; i < p.memoryTypeCount; ++i) if ((bits & (1u << i)) && (p.memoryTypes[i].propertyFlags & properties) == properties) return i; throw std::runtime_error("Required Vulkan memory type is unavailable"); } Buffer make_buffer(VkDeviceSize bytes, VkBufferUsageFlags usage, - VkMemoryPropertyFlags properties) { + VkMemoryPropertyFlags properties, VkMemoryPropertyFlags preferred = 0) { Buffer b{}; b.size = bytes; VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; @@ -208,8 +217,9 @@ struct Renderer::Impl { vkGetBufferMemoryRequirements(device, b.handle, &req); VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; alloc.allocationSize = req.size; - alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties); + alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties, preferred); check(vkAllocateMemory(device, &alloc, nullptr, &b.memory), "Allocate buffer memory"); + b.allocation_size = req.size; check(vkBindBufferMemory(device, b.handle, b.memory, 0), "Bind buffer memory"); } catch (...) { destroy(b); @@ -240,6 +250,7 @@ struct Renderer::Impl { memory_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); check(vkAllocateMemory(device, &alloc, nullptr, &image.memory), "Allocate image memory"); + image.allocation_size = req.size; check(vkBindImageMemory(device, image.handle, image.memory, 0), "Bind image memory"); VkImageViewCreateInfo view{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; view.image = image.handle; @@ -338,6 +349,7 @@ struct Renderer::Impl { bool validation = c.validation && std::any_of(layers.begin(), layers.end(), [](auto& p) { return std::strcmp(p.layerName, "VK_LAYER_KHRONOS_validation") == 0; }); + statistics.validation_enabled = validation; if (c.validation && !validation) std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n"; if (validation) @@ -488,9 +500,11 @@ struct Renderer::Impl { VK_IMAGE_ASPECT_COLOR_BIT); depth = make_image(width, height, VK_FORMAT_D32_SFLOAT, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_IMAGE_ASPECT_DEPTH_BIT); + // CPU reads this allocation every frame. Prefer cached coherent memory when available. readback = make_buffer(VkDeviceSize(width) * height * 4, VK_BUFFER_USAGE_TRANSFER_DST_BIT, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + VK_MEMORY_PROPERTY_HOST_CACHED_BIT); last_pixels.clear(); } void make_swapchain() { @@ -680,37 +694,34 @@ struct Renderer::Impl { auto [inserted, _] = textures.emplace(source.get(), std::move(texture)); return inserted->second.descriptor; } - VkShaderModule shader(const char* name) { + std::filesystem::path shader_directory() const { + if (!config.shader_directory.empty()) + return config.shader_directory; std::vector roots; const char* base = SDL_GetBasePath(); if (base) roots.emplace_back(std::filesystem::path(base) / "shaders"); roots.emplace_back(std::filesystem::current_path() / "shaders"); roots.emplace_back(FASET_SHADER_DIRECTORY); - std::ifstream file; - for (const auto& root : roots) { - file.open(root / (std::string(name) + ".spv"), std::ios::binary | std::ios::ate); - if (file) - break; - file.clear(); - } - if (!file) - throw std::runtime_error(std::string("Compiled Slang shader missing: ") + name + - ".spv"); - auto size = file.tellg(); - if (size <= 0 || size % 4 != 0) - throw std::runtime_error("Invalid SPIR-V byte length"); - std::vector bytes(static_cast(size) / 4); - file.seekg(0); - file.read(reinterpret_cast(bytes.data()), size); + for (const auto& root : roots) + if (std::filesystem::is_regular_file(root / "vertexMain.spv")) + return root; + throw std::runtime_error("Compiled Slang shader bundle is missing"); + } + VkShaderModule shader(const detail::ShaderCode& code) { VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; - ci.codeSize = static_cast(size); - ci.pCode = bytes.data(); + ci.codeSize = code.words.size() * sizeof(std::uint32_t); + ci.pCode = code.words.data(); VkShaderModule result{}; check(vkCreateShaderModule(device, &ci, nullptr, &result), "Create shader module"); return result; } void make_pipelines() { + const auto shaders = detail::load_shader_bundle(shader_directory()); + for (std::size_t i = 0; i < shaders.size(); ++i) + if (!shader_layouts[i].empty() && shader_layouts[i] != shaders[i].layout_fingerprint) + throw std::runtime_error( + "Shader layout changed; the current pipeline was preserved"); VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(Push)}; VkPipelineLayoutCreateInfo li{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; @@ -722,9 +733,9 @@ struct Renderer::Impl { "Create pipeline layout"); VkShaderModule vertex{}, fragment{}, shadow_vertex{}; try { - vertex = shader("vertexMain"); - fragment = shader("fragmentMain"); - shadow_vertex = shader("shadowMain"); + vertex = shader(shaders[0]); + fragment = shader(shaders[1]); + shadow_vertex = shader(shaders[2]); for (int mode = 0; mode < 3; ++mode) { bool shadow_pass = mode == 2, ui = mode == 1; VkPipelineShaderStageCreateInfo stages[2]{}; @@ -821,6 +832,8 @@ struct Renderer::Impl { vkDestroyShaderModule(device, shadow_vertex, nullptr); throw; } + for (std::size_t i = 0; i < shaders.size(); ++i) + shader_layouts[i] = shaders[i].layout_fingerprint; vkDestroyShaderModule(device, vertex, nullptr); vkDestroyShaderModule(device, fragment, nullptr); vkDestroyShaderModule(device, shadow_vertex, nullptr); @@ -953,12 +966,17 @@ struct Renderer::Impl { void render(const Snapshot& snapshot) { auto start = std::chrono::steady_clock::now(); statistics.draw_calls = statistics.culled_meshes = 0; + bool can_present = surface != VK_NULL_HANDLE; if (surface) { + // A capture may render between normal event-loop iterations. Keep the window + // system progressing without consuming events intended for the editor. + SDL_PumpEvents(); int w{}, h{}; SDL_GetWindowSizeInPixels(window, &w, &h); - if (w <= 0 || h <= 0) - return; - if (dirty_swapchain || !swapchain) + can_present = + w > 0 && h > 0 && + !(SDL_GetWindowFlags(window) & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)); + if (can_present && (dirty_swapchain || !swapchain)) make_swapchain(); } // Retire atlas/image resources no longer retained by a caller. @@ -1077,19 +1095,21 @@ struct Renderer::Impl { {direction[0], direction[1], direction[2], 0}, {snapshot.eye[0], snapshot.eye[1], snapshot.eye[2], 1}}; std::optional swap_index; - if (surface) { + if (can_present) { std::uint32_t index{}; - auto result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, acquired, - VK_NULL_HANDLE, &index); + // Compositors can withhold images while a window is occluded. Rendering and + // editor capture must remain available even when presentation cannot advance. + const auto result = + vkAcquireNextImageKHR(device, swapchain, 0, acquired, VK_NULL_HANDLE, &index); if (result == VK_ERROR_OUT_OF_DATE_KHR) { dirty_swapchain = true; - return; - } - if (result == VK_SUBOPTIMAL_KHR) - dirty_swapchain = true; - else + } else if (result == VK_SUCCESS || result == VK_SUBOPTIMAL_KHR) { + swap_index = index; + if (result == VK_SUBOPTIMAL_KHR) + dirty_swapchain = true; + } else if (result != VK_NOT_READY && result != VK_TIMEOUT) { check(result, "Acquire swapchain image"); - swap_index = index; + } } begin(); if (timestamp_pool) { @@ -1255,12 +1275,22 @@ struct Renderer::Impl { check(result, "Present frame"); check(vkQueueWaitIdle(queue), "Wait presentation"); } + const auto readback_started = std::chrono::steady_clock::now(); last_pixels.resize(std::size_t(width) * height * 4); check(vkMapMemory(device, readback.memory, 0, readback.size, 0, &mapped), "Map captured frame"); std::memcpy(last_pixels.data(), mapped, last_pixels.size()); vkUnmapMemory(device, readback.memory); + statistics.readback_cpu_ms = std::chrono::duration( + std::chrono::steady_clock::now() - readback_started) + .count(); ++statistics.frame; + statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size + + color.allocation_size + depth.allocation_size + + shadow.allocation_size; + statistics.texture_count = static_cast(textures.size()); + for (const auto& [_, texture] : textures) + statistics.gpu_allocated_bytes += texture.image.allocation_size; statistics.validation_errors = validation_errors.load(); statistics.cpu_ms = std::chrono::duration(std::chrono::steady_clock::now() - start) diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp new file mode 100644 index 0000000..616582d --- /dev/null +++ b/src/render/shader_contract.cpp @@ -0,0 +1,140 @@ +#include "shader_contract.hpp" +#include +#include +#include +#include +#include +#include + +namespace faset::render { +namespace { +using faset::Json; +void require(bool value, const std::string& message) { + if (!value) + throw std::runtime_error("Shader contract: " + message); +} +std::string read_bounded(const std::filesystem::path& path, std::uintmax_t maximum) { + require(std::filesystem::is_regular_file(path), "missing " + path.string()); + require(std::filesystem::file_size(path) <= maximum, "oversized " + path.string()); + return faset::read_text(path); +} +void locations(const Json& fields, std::initializer_list types, const char* label) { + require(fields.is_array() && fields.size() == types.size(), + std::string(label) + " count changed"); + std::size_t index{}; + for (const auto* type : types) { + require(fields[index].at("location") == index && fields[index].at("type") == type, + std::string(label) + " location/type changed"); + ++index; + } +} +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"); + 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, + "descriptor set, binding or array count changed"); + require(binding.at("type") == (i % 2 ? "sampler" : "sampled_image_2d"), + "descriptor type changed"); + require(fragment || !binding.at("used").get(), + "vertex texture bindings are unsupported"); + } + const auto& constants = layout.at("push_constants"); + require(constants.is_array() && constants.size() == 1 && constants[0].at("offset") == 0 && + constants[0].at("size") == 96, + "push-constant block changed"); + const auto& members = constants[0].at("members"); + require(members.is_array() && members.size() == 3, "push-constant member count changed"); + const int offsets[] = {0, 64, 80}, sizes[] = {64, 16, 16}; + const char* types[] = {"float32x4x4", "float32x4", "float32x4"}; + for (std::size_t i = 0; i < 3; ++i) + require(members[i].at("offset") == offsets[i] && members[i].at("size") == sizes[i] && + members[i].at("type") == types[i], + "push-constant member layout changed"); + const auto& blocks = layout.at("spirv_push_constants"); + require(blocks.is_array() && blocks.size() <= 1, "SPIR-V push-constant block count changed"); + for (const auto& block : blocks) { + const auto& actual = block.at("members"); + require(actual.is_array() && actual.size() == 3, "SPIR-V push-constant members changed"); + for (std::size_t i = 0; i < 3; ++i) + require(actual[i].at("member") == i && actual[i].at("offset") == offsets[i], + "SPIR-V push-constant offsets changed"); + // Slang lowers column-major host matrices to a transposed SPIR-V matrix type. + require(actual[0].at("matrix_layout") == "row-major" && actual[0].at("matrix_stride") == 16, + "SPIR-V matrix storage convention changed"); + } + if (fragment) { + locations(layout.at("inputs"), + {"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"}, + "fragment inputs"); + locations(layout.at("outputs"), {"float32x4"}, "fragment outputs"); + } else { + locations(layout.at("inputs"), + {"float32x4", "float32x3", "float32x3", "float32x4", "float32x2", "float32x2"}, + "vertex inputs"); + if (entry == "vertexMain") + locations(layout.at("outputs"), + {"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"}, + "vertex outputs"); + else + locations(layout.at("outputs"), {}, "shadow outputs"); + } +} +void validate_spirv(const std::vector& words, bool fragment) { + require(words.size() >= 5 && words[0] == 0x07230203 && words[1] >= 0x00010000 && + words[1] <= 0x00010600 && words[3] > 0 && words[3] < (1u << 20) && words[4] == 0, + "invalid SPIR-V header"); + bool entry_found{}; + for (std::size_t offset = 5; offset < words.size();) { + const auto count = words[offset] >> 16; + const auto opcode = words[offset] & 0xffff; + require(count > 0 && count <= words.size() - offset, "malformed SPIR-V instruction"); + if (opcode == 15) { // OpEntryPoint + require(count >= 4, "malformed SPIR-V entry point"); + const char* name = reinterpret_cast(&words[offset + 3]); + const auto available = (count - 3) * sizeof(std::uint32_t); + const auto* terminator = static_cast(std::memchr(name, 0, available)); + require(terminator != nullptr, "unterminated SPIR-V entry name"); + if (std::string_view(name, terminator - name) == "main") { + require(words[offset + 1] == (fragment ? 4u : 0u), "SPIR-V entry stage changed"); + entry_found = true; + } + } + offset += count; + } + require(entry_found, "SPIR-V main entry point missing"); +} +detail::ShaderCode load(const std::filesystem::path& directory, const char* entry) { + const auto bytes = read_bounded(directory / (std::string(entry) + ".spv"), 16 * 1024 * 1024); + require(bytes.size() >= 20 && bytes.size() % 4 == 0, "invalid SPIR-V byte length"); + const auto metadata = Json::parse( + read_bounded(directory / (std::string(entry) + ".reflection.json"), 1024 * 1024)); + require(metadata.at("format") == "faset.shader-reflection" && metadata.at("version") == 1, + "unsupported reflection version"); + require(metadata.at("source_entry") == entry && metadata.at("entry_point") == "main", + "reflection entry point mismatch"); + require(metadata.at("spirv_sha256") == faset::sha256(bytes), "SPIR-V/reflection hash mismatch"); + const auto& layout = metadata.at("layout"); + const auto fingerprint = faset::sha256(layout.dump()); + require(metadata.at("layout_fingerprint") == fingerprint, "layout fingerprint mismatch"); + validate_layout(layout, entry); + detail::ShaderCode result; + result.layout_fingerprint = fingerprint; + result.words.resize(bytes.size() / 4); + std::memcpy(result.words.data(), bytes.data(), bytes.size()); + validate_spirv(result.words, std::string_view(entry) == "fragmentMain"); + return result; +} +} // namespace +std::array +detail::load_shader_bundle(const std::filesystem::path& directory) { + return {load(directory, "vertexMain"), load(directory, "fragmentMain"), + load(directory, "shadowMain")}; +} +void validate_shader_bundle(const std::filesystem::path& directory) { + (void)detail::load_shader_bundle(directory); +} +} // namespace faset::render diff --git a/src/render/shader_contract.hpp b/src/render/shader_contract.hpp new file mode 100644 index 0000000..c0154ea --- /dev/null +++ b/src/render/shader_contract.hpp @@ -0,0 +1,14 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace faset::render::detail { +struct ShaderCode { + std::vector words; + std::string layout_fingerprint; +}; +std::array load_shader_bundle(const std::filesystem::path& directory); +} // namespace faset::render::detail diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp index ff57c77..4e54ade 100644 --- a/src/ui/ui.cpp +++ b/src/ui/ui.cpp @@ -115,6 +115,8 @@ Layout parse_layout(const Json& j, Layout l = {}) { } } // namespace Theme Theme::from_json(const Json& j) { + if (!j.is_object()) + throw std::runtime_error("Theme must be an object"); Theme t; #define UI_COLOR(name) t.name = color(j, #name, t.name) UI_COLOR(background); @@ -277,7 +279,9 @@ struct Context::Impl { return 5 * scale; if (horizontal) { if (w.kind == Kind::Label || w.kind == Kind::Button || w.kind == Kind::Tab) - return font.measure(w.text, theme.font_size * scale) + theme.padding * 2 * scale; + return font.measure(w.text, + (w.font_size > 0 ? w.font_size : theme.font_size) * scale) + + theme.padding * 2 * scale; return 80 * scale; } if (w.kind == Kind::Panel || w.kind == Kind::Column || w.kind == Kind::Row) { @@ -367,6 +371,8 @@ struct Context::Impl { Widget* hit(Widget& w, float x, float y) { if (!w.visible || !w.clip.contains(x, y)) return nullptr; + if (!w.enabled) + return w.rect.contains(x, y) ? &w : nullptr; for (auto child = w.children.rbegin(); child != w.children.rend(); ++child) if (auto* target = hit(**child, x, y)) return target; @@ -463,8 +469,9 @@ struct Context::Impl { while (i < e.buffer.text().size() && (static_cast(e.buffer.text()[i]) & 0xc0) == 0x80) ++i; - const auto width = font.measure(std::string_view(e.buffer.text()).substr(0, i), - theme.font_size * scale); + const auto width = + font.measure(std::string_view(e.buffer.text()).substr(0, i), + (w.font_size > 0 ? w.font_size : theme.font_size) * scale); if (local < (previous_width + width) * .5f) return previous_byte; previous_width = width; @@ -518,7 +525,11 @@ struct Context::Impl { fill(frame, rect, theme.surface, w.clip); outline(frame, rect, theme.border, w.clip); } else if (w.kind == Kind::Button) { - fill(frame, rect, hover && w.enabled ? theme.hover : theme.raised, w.clip); + fill(frame, rect, + hover && w.enabled ? theme.hover + : w.selected ? theme.selection + : theme.raised, + w.clip); outline(frame, rect, focus ? theme.accent : theme.border, w.clip); } else if (w.kind == Kind::Tab || w.kind == Kind::TreeRow) { if (w.selected || hover) @@ -543,7 +554,7 @@ struct Context::Impl { fill(frame, rect, hover || w.id == captured ? theme.accent : theme.background, w.clip); } float tx = rect.x + theme.padding * scale + w.indent * 14 * scale; - const auto font_size = theme.font_size * scale; + const auto font_size = (w.font_size > 0 ? w.font_size : theme.font_size) * scale; const auto ty = rect.y + std::max(0.f, (rect.height - font_size * 1.45f) * .5f); if (w.kind == Kind::Checkbox) { Rect box{tx, rect.y + (rect.height - 14 * scale) / 2, 14 * scale, 14 * scale}; @@ -666,30 +677,55 @@ const Theme& Context::theme() const { FontAtlas& Context::font() { return impl_->font; } -void Context::apply_layout(const Json& document) { +void Context::validate_layout(const Json& document) const { + if (!document.is_object()) + throw std::runtime_error("Layout must be an object"); const auto& definition = document.contains("root") ? document.at("root") : document; + if (definition.at("id") != impl_->root.id) + throw std::runtime_error("Layout root ID must match retained root"); std::set ids; - std::function validate = [&](const Json& j) { + std::function validate = [&](const Json& j, + const std::string& parent) { const auto id = j.at("id").get(); if (id.empty() || !ids.insert(id).second) throw std::runtime_error("Duplicate layout widget ID"); + const auto* existing = impl_->find(id); + if (existing && ((existing->parent ? existing->parent->id : std::string()) != parent)) + throw std::runtime_error("Hot layout cannot reparent existing widget: " + id); if (j.contains("kind")) { const auto type = kind_from_string(j.at("kind")); - if (auto* existing = find(id); existing && existing->kind != type) + if (existing && existing->kind != type) throw std::runtime_error("Hot layout cannot replace widget kind"); } + if (j.contains("text") && + (!j.at("text").is_string() || !TextBuffer::valid_utf8(j.at("text").get()))) + throw std::runtime_error("Widget text must be valid UTF-8"); if (j.contains("layout")) - parse_layout(j.at("layout")); - if (j.contains("children")) + parse_layout(j.at("layout"), existing ? existing->layout : Layout{}); + if (j.contains("font_size")) { + const auto size = j.at("font_size").get(); + if (!std::isfinite(size) || size < 0 || size > 128) + throw std::runtime_error("Invalid widget font size"); + } + if (j.contains("children")) { + if (!j.at("children").is_array()) + throw std::runtime_error("Layout children must be an array"); for (const auto& child : j.at("children")) - validate(child); + validate(child, id); + } }; - validate(definition); + validate(definition, ""); +} +void Context::apply_layout(const Json& document) { + validate_layout(document); + const auto& definition = document.contains("root") ? document.at("root") : document; std::function apply = [&](Widget& w, const Json& j) { if (j.contains("text")) update_text(w.id, j.at("text")); if (j.contains("layout")) w.layout = parse_layout(j.at("layout"), w.layout); + if (j.contains("font_size")) + w.font_size = j.at("font_size"); if (j.contains("children")) for (const auto& child : j.at("children")) { const auto id = child.at("id").get(); @@ -700,8 +736,6 @@ void Context::apply_layout(const Json& document) { apply(target, child); } }; - if (definition.at("id") != root().id) - throw std::runtime_error("Layout root ID must match retained root"); apply(root(), definition); } void Context::layout(float width, float height, float scale) { diff --git a/tests/assets_image_fixtures.hpp b/tests/assets_image_fixtures.hpp new file mode 100644 index 0000000..46add82 --- /dev/null +++ b/tests/assets_image_fixtures.hpp @@ -0,0 +1,34 @@ +#pragma once +#include +// Deterministic two-pixel test images generated locally; no external media. +namespace faset::test_images { +inline const std::vector png_red_green = { + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, + 2, 0, 0, 0, 1, 8, 6, 0, 0, 0, 244, 34, 127, 138, 0, 0, 0, 17, 73, + 68, 65, 84, 120, 218, 99, 248, 207, 192, 240, 159, 225, 63, 67, 3, 0, 16, 121, 3, + 126, 92, 47, 98, 147, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130}; +inline const std::vector png_blue_white = { + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, + 2, 0, 0, 0, 1, 8, 6, 0, 0, 0, 244, 34, 127, 138, 0, 0, 0, 17, 73, + 68, 65, 84, 120, 218, 99, 100, 96, 248, 255, 255, 255, 127, 6, 6, 0, 18, 0, 3, + 254, 224, 181, 163, 188, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130}; +inline const std::vector jpeg_red_green = { + 255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 1, 0, 0, 1, 0, 1, 0, + 0, 255, 219, 0, 67, 0, 2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 2, 2, + 2, 2, 4, 3, 2, 2, 2, 2, 5, 4, 4, 3, 4, 6, 5, 6, 6, 6, 5, + 6, 6, 6, 7, 9, 8, 6, 7, 9, 7, 6, 6, 8, 11, 8, 9, 10, 10, 10, + 10, 10, 6, 8, 11, 12, 11, 10, 12, 9, 10, 10, 10, 255, 219, 0, 67, 1, 2, + 2, 2, 2, 2, 2, 5, 3, 3, 5, 10, 7, 6, 7, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 255, 192, 0, 17, 8, 0, 1, 0, 2, 3, 1, 34, 0, + 2, 17, 1, 3, 17, 1, 255, 196, 0, 21, 0, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 255, 196, 0, 27, 16, 1, 0, 0, 7, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 4, 6, 7, 55, 118, + 180, 255, 196, 0, 20, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 9, 255, 196, 0, 32, 17, 0, 0, 3, 9, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 2, 4, 1, 3, 5, 7, 52, 55, 114, 116, 177, 179, + 255, 218, 0, 12, 3, 1, 0, 2, 17, 3, 17, 0, 63, 0, 179, 179, 24, 122, 147, + 214, 164, 57, 224, 0, 92, 174, 173, 123, 145, 186, 208, 70, 205, 27, 153, 28, 220, 83, + 236, 113, 255, 217}; +} // namespace faset::test_images diff --git a/tests/assets_pipeline.cpp b/tests/assets_pipeline.cpp index 7f122aa..882d3bf 100644 --- a/tests/assets_pipeline.cpp +++ b/tests/assets_pipeline.cpp @@ -1,3 +1,4 @@ +#include "assets_image_fixtures.hpp" #include #include #include @@ -111,6 +112,16 @@ int main() { glb(source); auto first = pipeline.import_asset({source}); success(first); + require(first.manifest.at("input_key").at("target_profile") == "desktop-static-pbr-v1" && + first.manifest.at("input_key") + .at("toolchain") + .at("cgltf") + .get() + .size() == 40, + "cache recipe omits the desktop profile or pinned importer toolchain"); + require(first.manifest.at("materials").at(0).at("format") == "faset.material" && + first.manifest.at("materials").at(0).at("version") == 1, + "material data is not explicitly versioned"); require(!first.asset_id.empty(), "persistent identity missing"); auto asset = pipeline.load_asset(first.asset_id); require(asset.meshes.size() == 1 && asset.nodes.size() == 1, "mesh/node extraction"); @@ -125,6 +136,13 @@ int main() { {"physics", {{"mass", 12}}}, {"material", "custom-brass"}}}}; pipeline.set_overrides(first.asset_id, custom); + AssetPipeline rebuilt_cache(root / "rebuilt-cache"); + const auto recovered = rebuilt_cache.import_asset({source}); + success(recovered); + require(recovered.asset_id == first.asset_id && recovered.generation == first.generation && + rebuilt_cache.load_asset(first.asset_id).nodes[0].id == node_id && + rebuilt_cache.overrides(first.asset_id) == custom, + "fresh cache changed source identity, outputs, or authoring overrides"); auto unchanged = pipeline.import_asset({source}); success(unchanged); require(unchanged.cache_hit && unchanged.generation == first.generation, @@ -258,6 +276,99 @@ int main() { "concurrent dependency edit accepted"); require(pipeline.load_asset(ext.asset_id).generation == dependency_active, "concurrent edit changed active"); + // Standalone images share the same identity, generation and failure guarantees. + const auto picture = root / "sprite.PNG"; + save(picture, faset::test_images::png_red_green); + ImportRequest image_request{picture}; + image_request.settings = {{"pixels_per_unit", 20.0}}; + auto image_first = pipeline.import_asset(image_request); + success(image_first); + auto image_asset = pipeline.load_asset(image_first.asset_id); + require(image_asset.meshes.empty() && image_asset.textures.size() == 1, + "Standalone PNG should produce one owned texture"); + require(image_asset.textures[0].mime_type == "image/png" && + image_first.manifest["kind"] == "image", + "PNG kind/MIME"); + require(image_first.manifest["image"]["width"] == 2 && + image_first.manifest["image"]["height"] == 1 && + image_first.manifest["image"]["pixels_per_unit"] == 20.0, + "PNG dimensions and sprite scale recipe"); + const auto texture_id = image_asset.textures[0].id; + pipeline.set_overrides(image_first.asset_id, {{texture_id, {{"gameplay", "preserved"}}}}); + auto image_cache = pipeline.import_asset({picture}); + success(image_cache); + require(image_cache.cache_hit && image_cache.generation == image_first.generation, + "PNG cache/recipe persistence"); + save(picture, faset::test_images::png_blue_white); + auto image_changed = pipeline.import_asset({picture}); + success(image_changed); + require(image_changed.asset_id == image_first.asset_id && + image_changed.generation != image_first.generation, + "PNG reimport content invalidation"); + require(pipeline.load_asset(image_first.asset_id).textures[0].id == texture_id, + "Image subasset ID changed after content edit"); + image_request.settings = {{"pixels_per_unit", 40.0}}; + auto image_recipe = pipeline.import_asset(image_request); + success(image_recipe); + require(image_recipe.generation != image_changed.generation, + "Image settings omitted from recipe hash"); + save(picture, std::string("broken PNG")); + require(pipeline.import_asset({picture}).status == ImportStatus::failed, + "Broken PNG published"); + require(pipeline.load_asset(image_first.asset_id).generation == image_recipe.generation, + "Broken PNG replaced successful generation"); + save(picture, faset::test_images::jpeg_red_green); + require(pipeline.import_asset({picture}).status == ImportStatus::failed, + "Mismatched JPEG content accepted as PNG"); + auto oversized = faset::test_images::png_red_green; + oversized[16] = 0; + oversized[17] = 1; + oversized[18] = 0; + oversized[19] = 0; + save(picture, oversized); + require(pipeline.import_asset({picture}).status == ImportStatus::failed, + "Oversized image accepted"); + save(picture, faset::test_images::png_red_green); + ImportJob* image_job_pointer = nullptr; + ImportJob image_job([&](const ImportProgress& progress) { + if (progress.fraction >= .5f) + image_job_pointer->cancel(); + }); + image_job_pointer = &image_job; + require(pipeline.import_asset({picture}, image_job).status == ImportStatus::cancelled, + "PNG cancellation ignored"); + require(pipeline.load_asset(image_first.asset_id).generation == image_recipe.generation, + "Cancelled PNG replaced active generation"); + image_request.settings = {{"pixels_per_unit", 0}}; + require(pipeline.import_asset(image_request).status == ImportStatus::failed, + "Zero pixels_per_unit accepted"); + save(picture, faset::test_images::png_blue_white); + const auto renamed = root / "renamed.PNG"; + fs::rename(picture, renamed); + fs::rename(picture.string() + ".faset-import.json", + renamed.string() + ".faset-import.json"); + fs::rename(picture.string() + ".faset-overrides.json", + renamed.string() + ".faset-overrides.json"); + auto image_rename = pipeline.import_asset({renamed}); + success(image_rename); + require(image_rename.cache_hit && image_rename.asset_id == image_first.asset_id, + "Image source rename with sidecar lost identity/cache"); + require(pipeline.current_manifest(image_first.asset_id)["source"] == renamed.string(), + "Image rename retained old source pointer"); + require(pipeline.overrides(image_first.asset_id).contains(texture_id), + "Image rename/reimport lost overrides"); + const auto jpeg = root / "sprite.JPEG"; + save(jpeg, faset::test_images::jpeg_red_green); + auto jpeg_import = pipeline.import_asset({jpeg}); + success(jpeg_import); + require(jpeg_import.manifest["image"]["width"] == 2 && + pipeline.load_asset(jpeg_import.asset_id).textures[0].mime_type == "image/jpeg", + "JPEG decoding/dimensions"); + save(root / "same.png", faset::test_images::png_blue_white); + auto other_image = pipeline.import_asset({root / "same.png"}); + success(other_image); + require(other_image.asset_id != image_first.asset_id, + "Independent same-content source must have independent AssetId"); fs::remove_all(root / "cache"); auto restored = pipeline.import_asset({source}); success(restored); @@ -268,7 +379,7 @@ int main() { "cleared cache lost authoring overrides"); fs::remove_all(root); std::cout << "assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, " - "failure, cancellation OK\n"; + "failure, cancellation, standalone PNG/JPEG OK\n"; return 0; } catch (const std::exception& e) { std::cerr << e.what() << "\nFixtures retained: " << root << '\n'; diff --git a/tests/authoring_tests.cpp b/tests/authoring_tests.cpp index 20b4d5c..05b5e6d 100644 --- a/tests/authoring_tests.cpp +++ b/tests/authoring_tests.cpp @@ -80,6 +80,28 @@ int main() { atomic_write(root / "Scenes/courtyard.scene.json", read_text(root / "Scenes/courtyard.scene.json") + "\n"); fails([&] { restarted.save(id); }, "save.disk_conflict"); + const auto configured = service.create("Configured scene"); + const std::string configured_id = configured.at("id"); + const auto setting = service.transact( + configured_id, 0, + Json::array({{{"op", "scene.simulation"}, + {"value", {{"fixed_delta", 0.02}, {"gravity", {0, -10, 0}}}}}})); + CHECK(setting["scene"]["simulation"]["fixed_delta"] == 0.02); + fails( + [&] { + service.transact(configured_id, 1, + Json::array({{{"op", "scene.simulation"}, + {"value", {{"max_catch_up_ticks", 1.5}}}}})); + }, + "simulation.integer"); + fails( + [&] { + service.transact( + configured_id, 1, + Json::array({{{"op", "scene.simulation"}, {"value", {{"fixed_delta", 0}}}}})); + }, + "simulation.fixed_delta"); + CHECK(!service.undo(configured_id, 1)["scene"].contains("simulation")); // Parent cycles are rejected atomically; names never provide identity. fails( [&] { @@ -90,6 +112,25 @@ int main() { }, "entity.cycle"); CHECK(service.query(id)["revision"] == 5); + // A failed recovery publication rejects the entire mutation and its Undo record. + const auto fault = service.create("Journal failure"); + const std::string fault_id = fault["id"]; + const auto journal = root / ".faset/recovery" / (fault_id + ".json"); + const auto saved_journal = read_text(journal); + std::filesystem::remove(journal); + std::filesystem::create_directory(journal); + bool journal_failed = false; + try { + service.transact( + fault_id, 0, + Json::array({{{"op", "entity.create"}, {"name", "Must not publish"}}})); + } catch (const std::exception&) { + journal_failed = true; + } + CHECK(journal_failed); + CHECK(service.query(fault_id) == fault); + std::filesystem::remove(journal); + atomic_write(journal, saved_journal); // Unavailable extension data is retained, including fields unknown to this SDK. auto unknown = make_entity(schemas, "Plugin object"); unknown["components"].push_back({{"id", new_id()}, @@ -127,6 +168,82 @@ int main() { source["entities"] = Json::array(); CHECK(resolve_templates(outer, schemas, loader).conflicts.size() == 1); CHECK(outer["instances"][0]["overrides"].size() == 1); + // Template deletion, suppression restore and source repair are normal Undo transactions. + auto template_doc = service.create("Template management"); + const std::string template_id = template_doc["id"]; + service.transact( + template_id, 0, + Json::array({{{"op", "template.instance"}, + {"instance", {{"id", "inst"}, {"source", "Scenes/absent.json"}}}}})); + const Json suppressed = {{"path", Json::array()}, {"object", entity_id}}; + service.transact( + template_id, 1, + Json::array( + {{{"op", "template.suppress"}, {"instance", "inst"}, {"value", suppressed}}})); + auto restored = service.transact( + template_id, 2, + Json::array( + {{{"op", "template.restore"}, {"instance", "inst"}, {"address", suppressed}}, + {{"op", "template.source_set"}, + {"instance", "inst"}, + {"source", "Scenes/courtyard.scene.json"}}})); + CHECK(restored["scene"]["instances"][0]["suppressed"].empty()); + CHECK(service + .transact(template_id, 3, + Json::array({{{"op", "template.remove"}, + {"instance", "inst"}}}))["scene"]["instances"] + .empty()); + CHECK(service.undo(template_id, 4)["scene"]["instances"].size() == 1); + const auto added = make_entity(schemas, "Local object"); + service.transact( + template_id, 5, + Json::array({{{"op", "template.add"}, {"instance", "inst"}, {"value", added}}})); + auto replacement = added; + replacement["name"] = "Edited local object"; + replacement["components"].push_back({{"id", new_id()}, + {"type", "faset.mesh"}, + {"version", 1}, + {"fields", schemas.default_fields("faset.mesh")}}); + const auto replaced = service.transact( + template_id, 6, + Json::array( + {{{"op", "template.addition_set"}, {"instance", "inst"}, {"value", replacement}}})); + CHECK(replaced["scene"]["instances"][0]["additions"][0]["components"].size() == 2); + auto malformed = replacement; + malformed["components"][1]["fields"]["primitive"] = "unsupported"; + fails( + [&] { + service.transact(template_id, 7, + Json::array({{{"op", "template.addition_set"}, + {"instance", "inst"}, + {"value", malformed}}})); + }, + "validation.enum"); + // Malformed address records never reach journals; valid unresolved targets stay + // recoverable. + for (const auto& broken : Json::array( + {{{"id", "bad"}, + {"source", "missing"}, + {"overrides", Json::array({{{"address", "oops"}, {"value", 42}}})}}, + {{"id", "bad"}, + {"source", "missing"}, + {"suppressed", Json::array({{{"object", "ok"}, {"path", 42}}})}}, + {{"id", "bad"}, + {"source", "missing"}, + {"reparents", + Json::array({{{"object", {{"object", "ok"}}}, {"parent", false}}})}}})) { + const auto before = service.query(template_id); + bool rejected = false; + try { + service.transact( + template_id, before["revision"], + Json::array({{{"op", "template.instance"}, {"instance", broken}}})); + } catch (const Error&) { + rejected = true; + } + CHECK(rejected); + CHECK(service.query(template_id) == before); + } // Stable FieldId survives a label rename; incompatible migrations require an explicit // decision. SchemaRegistry newer; @@ -147,6 +264,53 @@ int main() { CHECK(migrated["fields"]["speed"] == 3.0); CHECK(migrated["fields"]["enabled"] == true); CHECK(migrated["fields"]["unrecognized"] == "preserve"); + // Local additions and overrides remap references within their own instance; future schemas + // stay opaque. + auto refs = builtin_schemas(); + refs.register_schema({{"id", "ref"}, + {"version", 1}, + {"fields", {{"target", {{"type", "entity_ref"}, {"default", ""}}}}}}); + auto ref_entity = [&](const std::string& name, const std::string& target, int version = 1) { + return Json{ + {"id", name}, + {"name", name}, + {"parent", nullptr}, + {"components", + Json::array({{{"id", name + "-ref"}, + {"type", "ref"}, + {"version", version}, + {"fields", {{"target", target}, {"opaque", {{"saved", true}}}}}}})}}; + }; + auto ref_source = make_scene("References"); + ref_source["entities"] = + Json::array({ref_entity("a", "b"), ref_entity("b", "a"), ref_entity("future", "a", 2)}); + auto ref_outer = make_scene("Instances"); + ref_outer["instances"] = Json::array( + {{{"id", "ref-instance"}, + {"source", "ref-source"}, + {"additions", Json::array({ref_entity("local", "b")})}, + {"overrides", + Json::array( + {{{"address", {{"object", "a"}, {"component", "a-ref"}, {"field", "target"}}}, + {"value", "local"}}})}}}); + const auto ref_result = + resolve_templates(ref_outer, refs, [&](const std::string&) { return ref_source; }); + CHECK(ref_result.conflicts.empty()); + const auto& re = ref_result.scene["entities"]; + CHECK(re[0]["components"][0]["fields"]["target"] == re[3]["id"]); + CHECK(re[3]["components"][0]["fields"]["target"] == re[1]["id"]); + CHECK(re[2]["components"][0]["fields"] == + ref_source["entities"][2]["components"][0]["fields"]); + AuthoringService opaque(root / "opaque", refs); + const auto opaque_doc = opaque.create("Future"); + const std::string opaque_id = opaque_doc["id"]; + opaque.transact( + opaque_id, 0, + Json::array({{{"op", "entity.create"}, {"entity", ref_entity("a", "a", 2)}}})); + const auto duplicated = opaque.transact( + opaque_id, 1, Json::array({{{"op", "entity.duplicate"}, {"entity", "a"}}})); + CHECK(duplicated["scene"]["entities"][1]["components"][0]["fields"] == + duplicated["scene"]["entities"][0]["components"][0]["fields"]); // Full TRS reparent, including parent rotation/scale, preserves world placement. auto hierarchy = make_scene("Transforms"); auto parent = make_entity(schemas, "Parent"); diff --git a/tests/editor_gui_mcp_test.py b/tests/editor_gui_mcp_test.py new file mode 100644 index 0000000..b87d578 --- /dev/null +++ b/tests/editor_gui_mcp_test.py @@ -0,0 +1,110 @@ +"""Real native Editor + stdio MCP regression; requires a desktop and Vulkan. + +Repeated fresh captures cover presentation back-pressure in hidden/occluded windows. +The test deliberately terminates its owned GUI process after assertions; closing a +client must not close the user's interactive Editor. +""" +import base64 +import json +from pathlib import Path +import queue +import subprocess +import sys +import tempfile +import threading + + +def run(executable, project): + process = subprocess.Popen( + [executable, "--project", str(project), "--new", "GUI MCP acceptance", + "--dimension", "3", "--mcp", "--gui"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8") + replies, diagnostics = queue.Queue(), [] + + def read_stdout(): + try: + for line in process.stdout: + replies.put(json.loads(line)) + except Exception as error: + replies.put(error) + finally: + replies.put(None) + + def read_stderr(): + for line in process.stderr: + diagnostics.append(line) + + readers = [threading.Thread(target=read_stdout, daemon=True), + threading.Thread(target=read_stderr, daemon=True)] + for reader in readers: + reader.start() + sequence = 0 + + def request(method, params): + nonlocal sequence + sequence += 1 + process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": sequence, + "method": method, "params": params}) + "\n") + process.stdin.flush() + response = replies.get(timeout=60) + assert isinstance(response, dict) and response.get("id") == sequence, response + assert "error" not in response, response + return response["result"] + + def call(name, arguments=None, error=False): + result = request("tools/call", {"name": name, "arguments": arguments or {}}) + assert result.get("isError", False) == error, result + return result + + try: + request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "faset-gui-acceptance", "version": "1"}}) + process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n') + process.stdin.flush() + document = call("faset_documents")["structuredContent"]["documents"][0] + edited = call("faset_scene_edit", { + "document": document["id"], "revision": document["revision"], + "operations": [{"op": "entity.create", "entity": { + "id": "mcp-cube", "name": "Shared GUI and MCP cube 世界", "parent": None, + "components": [ + {"id": "cube-transform", "type": "faset.transform", "version": 1, + "fields": {"position": [0, 1, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1]}}, + {"id": "cube-mesh", "type": "faset.mesh", "version": 1, + "fields": {"primitive": "cube", "asset": "", "color": [0.65, 0.5, 0.85, 1]}} + ]}}]})["structuredContent"] + call("faset_scene_edit", {"document": document["id"], "revision": document["revision"], + "operations": [{"op": "scene.rename", "name": "Stale"}]}, True) + images = [] + for index in range(12): + relative = ".faset/screenshots/full.png" if index == 0 else ".faset/screenshots/view.png" + result = call("faset_editor_capture", {"path": relative, "viewport_only": index != 0}) + image = next(item for item in result["content"] if item["type"] == "image") + data = base64.b64decode(image["data"], validate=True) + assert data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) > 1000 + assert (project / relative).read_bytes() == data + images.append(data) + assert call("faset_capabilities")["structuredContent"]["viewport_capture"] + call("faset_editor_capture", {"path": "../outside.png"}, True) + call("faset_editor_capture", {"path": "wrong.jpg"}, True) + undone = call("faset_undo", {"document": document["id"], + "revision": edited["revision"]})["structuredContent"] + assert all(entity["id"] != "mcp-cube" for entity in undone["scene"]["entities"]) + assert process.poll() is None + finally: + process.stdin.close() + process.terminate() + try: + process.wait(timeout=20) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) + for reader in readers: + reader.join(timeout=5) + errors = "".join(diagnostics) + assert "Validation Error" not in errors, errors + + +with tempfile.TemporaryDirectory(prefix="faset-gui-mcp-") as directory: + run(sys.argv[1], Path(directory)) +print("Native GUI + MCP shared authoring, revision conflict, Undo, 12 fresh PNG captures and continued responsiveness passed") diff --git a/tests/editor_session_tests.cpp b/tests/editor_session_tests.cpp new file mode 100644 index 0000000..77bcf72 --- /dev/null +++ b/tests/editor_session_tests.cpp @@ -0,0 +1,75 @@ +#include +#include +#include + +int main() { + using namespace faset; + const auto root = std::filesystem::temp_directory_path() / ("faset-session-" + new_id()); + try { + editor::Session session({root, FASET_TEST_ENGINE, {}}); + session.scaffold("Settings", 3); + auto document = session.authoring().create("Start", 3); + session.authoring().save(document.at("id"), "Scenes/main.scene.json"); + auto& commands = session.commands(); + const auto initial = commands.call("faset_project_settings_get", Json::object()); + auto changed = commands.call("faset_project_settings_set", + {{"revision", initial.at("revision")}, + {"settings", + {{"name", "Проект 世界"}, + {"dimension", 2}, + {"start_scene", "Scenes/main.scene.json"}}}}); + require(changed.at("settings").at("name") == "Проект 世界" && + session.project().at("dimension") == 2, + "test", "Project settings were not saved"); + require(session.authoring().query(document.at("id")).at("scene").at("dimension") == 3, + "test", "Project defaults changed the existing scene"); + auto rejects = [&](const Json& request, const std::string& code) { + try { + commands.call("faset_project_settings_set", request); + } catch (const Error& error) { + require(error.json().at("code") == code, "test", "Wrong project error"); + return; + } + throw std::runtime_error("Invalid project edit was accepted"); + }; + rejects({{"revision", initial.at("revision")}, {"settings", {{"name", "Stale"}}}}, + "revision.conflict"); + for (const auto& [field, value, code] : + std::vector>{ + {"name", "", "project.name"}, + {"dimension", 4, "project.dimension"}, + {"id", "replace-id", "project.setting"}, + {"start_scene", "Scenes/missing.scene.json", "project.start_scene"}}) { + rejects({{"revision", changed.at("revision")}, {"settings", {{field, value}}}}, code); + require(commands.call("faset_project_settings_get", Json::object()) == changed, "test", + "Rejected project edit changed state"); + } + auto external = session.project(); + external["custom_tool"] = {{"keep", true}}; + atomic_write_json(root / "project.faset.json", external); + rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}}, + "revision.conflict"); + const auto reloaded = commands.call("faset_project_settings_get", Json::object()); + 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"); + external["version"] = 999; + atomic_write_json(root / "project.faset.json", external); + bool rejected = false; + try { + session.project(); + } catch (const Error& error) { + rejected = error.json().at("code") == "project.version"; + } + require(rejected, "test", "Future project version was reinterpreted"); + std::filesystem::remove_all(root); + std::cout << "Project settings validation, save, external revision conflicts and opaque " + "metadata passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << "\nFixture retained at " << root << '\n'; + return 1; + } +} diff --git a/tests/editor_ui_gizmos.cpp b/tests/editor_ui_gizmos.cpp new file mode 100644 index 0000000..90fd246 --- /dev/null +++ b/tests/editor_ui_gizmos.cpp @@ -0,0 +1,167 @@ +#include +#include +#include +#include +using namespace faset; +namespace { +void check(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +render::Event mouse(render::Event::Type type, render::Vec2 p) { + render::Event result; + result.type = type; + result.button = 1; + result.x = p[0]; + result.y = p[1]; + return result; +} +void click(editor::EditorUI& ui, const std::string& id) { + const auto* widget = ui.widgets().find(id); + check(widget, "Missing gizmo test widget"); + const auto r = widget->rect.intersection(widget->clip); + check(r.width > 0 && r.height > 0, "Clipped gizmo test widget"); + const render::Vec2 p{r.x + r.width * .5f, r.y + r.height * .5f}; + ui.frame({mouse(render::Event::Type::MouseDown, p), mouse(render::Event::Type::MouseUp, p)}); +} +render::Vec2 project(editor::EditorUI& ui, render::Vec3 p) { + const auto& s = ui.snapshot(); + const auto& m = s.view_projection; + const float w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; + return {s.scene_rect[0] + + ((m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w + 1) * s.scene_rect[2] * .5f, + s.scene_rect[1] + ((m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w + 1) * + s.scene_rect[3] * .5f}; +} +render::Vec2 along(render::Vec2 a, render::Vec2 b, float t) { + return {a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t}; +} +void near(float a, float b, const char* message) { + check(std::abs(a - b) < .002f, message); +} +} // namespace +int main() { + const auto root = std::filesystem::temp_directory_path() / ("faset-gizmo-ui-" + new_id()); + try { + editor::Session session({root, FASET_TEST_ENGINE, root}); + render::Renderer renderer({1280, 900, "Parented gizmo acceptance", true, true}); + editor::EditorUI ui(session, renderer, + std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", + std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json"); + ui.frame({}); + auto query = [&] { return session.authoring().query(ui.current_document()); }; + auto parent = + authoring::make_entity(session.authoring().schemas(), "Rotated scaled parent"); + parent["components"][0]["fields"] = { + {"position", {.5, .6, 0}}, {"rotation", {0, .8, 0}}, {"scale", {2, 2, 2}}}; + auto child = + authoring::make_entity(session.authoring().schemas(), "Child", parent.at("id")); + child["components"].push_back( + {{"id", new_id()}, + {"type", "faset.mesh"}, + {"version", 1}, + {"fields", {{"asset", ""}, {"primitive", "cube"}, {"color", {.6, .6, .65, 1.}}}}}); + session.authoring().transact(ui.current_document(), query().at("revision"), + Json::array({{{"op", "entity.create"}, {"entity", parent}}, + {{"op", "entity.create"}, {"entity", child}}})); + ui.select_entity(child.at("id")); + ui.frame({}); + const auto original_world = render::transform({.5f, .6f, 0}, {0, .8f, 0}, {2, 2, 2}); + auto check_parent = [&] { + check(query()["scene"]["entities"][0] == parent, "Child gizmo must not mutate parent"); + }; + auto check_world = [&](render::Mat4 expected) { + check(!ui.snapshot().draws.empty(), "Rendered child mesh missing"); + for (std::size_t i = 0; i < 16; ++i) + near(ui.snapshot().draws[0].model[i], expected[i], + "Rendered child world transform mismatch"); + }; + check_world(original_world); + const auto center = project(ui, {.5f, .6f, 0}); + const auto tip = project(ui, {1.94f, .6f, 0}); + const auto before = query()["revision"].get(); + ui.frame({mouse(render::Event::Type::MouseDown, along(center, tip, .65f))}); + ui.frame({mouse(render::Event::Type::MouseMove, along(center, tip, .9f))}); + ui.frame({mouse(render::Event::Type::MouseMove, along(center, tip, 1.15f))}); + check(query()["revision"] == before, "Multi-frame Move only previews before release"); + auto moved = original_world; + moved[12] += .72f; + check_world(moved); + ui.frame({mouse(render::Event::Type::MouseUp, along(center, tip, 1.15f))}); + check(query()["revision"] == before + 1, "Move commits exactly one transaction"); + check_world(moved); + check_parent(); + click(ui, "undo"); + check_world(original_world); + + // Rotate and Scale expose the selected object's local axes, including its parent. + const render::Vec3 local_x{std::cos(.8f), 0, -std::sin(.8f)}; + const auto local_tip = project(ui, {.5f + local_x[0] * 1.44f, .6f, local_x[2] * 1.44f}); + for (const std::string mode : {"Rotate", "Scale"}) { + click(ui, "gizmo-" + mode); + const auto revision = query()["revision"].get(); + ui.frame({mouse(render::Event::Type::MouseDown, along(center, local_tip, .7f))}); + ui.frame({mouse(render::Event::Type::MouseMove, along(center, local_tip, .85f))}); + ui.frame({mouse(render::Event::Type::MouseMove, along(center, local_tip, 1.f))}); + check(query()["revision"] == revision, "Local-axis gizmo preview does not author"); + ui.frame({mouse(render::Event::Type::MouseUp, along(center, local_tip, 1.f))}); + check(query()["revision"] == revision + 1, "Local-axis gizmo commits one transaction"); + const auto fields = query()["scene"]["entities"][1]["components"][0]["fields"]; + if (mode == "Rotate") { + near(fields["rotation"][0], .3f * 3.141593f, "Rotate edits local X radians"); + near(fields["rotation"][1], 0, "Rotate preserves local Y"); + check_world(render::multiply(original_world, + render::transform({}, {.3f * 3.141593f, 0, 0}))); + } else { + near(fields["scale"][0], 1.3f, "Scale edits local X"); + near(fields["scale"][1], 1, "Scale preserves local Y"); + check_world( + render::multiply(original_world, render::transform({}, {}, {1.3f, 1, 1}))); + } + check_parent(); + click(ui, "undo"); + check_world(original_world); + } + click(ui, "gizmo-Move"); + const auto cancelled = query()["revision"]; + ui.frame({mouse(render::Event::Type::MouseDown, along(center, tip, .65f))}); + ui.frame({mouse(render::Event::Type::MouseMove, along(center, tip, 1.15f))}); + render::Event escape; + escape.type = render::Event::Type::KeyDown; + escape.key = "Escape"; + ui.frame({escape, mouse(render::Event::Type::MouseUp, along(center, tip, 1.15f))}); + check(query()["revision"] == cancelled, "Escape cancels gizmo without authoring"); + check_world(original_world); + session.authoring().transact(ui.current_document(), query().at("revision"), + Json::array({{{"op", "component.set"}, + {"entity", child.at("id")}, + {"component", child["components"][0]["id"]}, + {"field", "rotation"}, + {"value", {.3, .4, .2}}}})); + ui.frame({}); + click(ui, "gizmo-Rotate"); + const auto rotated_local = render::transform({}, {.3f, .4f, .2f}); + const auto rotated_world = render::multiply(original_world, rotated_local); + check_world(rotated_world); + const auto y_tip = project(ui, {.5f + rotated_world[4] * .72f, + .6f + rotated_world[5] * .72f, rotated_world[6] * .72f}); + ui.frame({mouse(render::Event::Type::MouseDown, along(center, y_tip, .7f))}); + ui.frame({mouse(render::Event::Type::MouseMove, along(center, y_tip, .9f))}); + ui.frame({mouse(render::Event::Type::MouseUp, along(center, y_tip, .9f))}); + check_world( + render::multiply(rotated_world, render::transform({}, {0, .2f * 3.141593f, 0}))); + check_parent(); + click(ui, "undo"); + check_world(rotated_world); + renderer.render(ui.snapshot()); + renderer.capture(root / "gizmos.ppm"); + check(renderer.stats().validation_errors == 0, "Vulkan validation"); + std::cout << "Parented world Move/local Rotate/local Scale, multi-frame preview, Undo, " + "Escape and composed local rotation passed. " + << root << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << "\nRetained: " << root << '\n'; + return 1; + } +} diff --git a/tests/editor_ui_launcher.cpp b/tests/editor_ui_launcher.cpp new file mode 100644 index 0000000..5816895 --- /dev/null +++ b/tests/editor_ui_launcher.cpp @@ -0,0 +1,138 @@ +#include +#include +#include +using namespace faset; +namespace { +void check(bool value, const std::string& message) { + if (!value) + throw std::runtime_error(message); +} +render::Event key(std::string name, bool control = false) { + render::Event e; + e.type = render::Event::Type::KeyDown; + e.key = std::move(name); + e.control = control; + return e; +} +void click(editor::ProjectLauncher& launcher, const std::string& id) { + const auto* w = launcher.widgets().find(id); + check(w, "Missing launcher widget: " + id); + const auto rect = w->rect.intersection(w->clip); + check(rect.width > 0 && rect.height > 0, "Hidden launcher widget: " + id); + render::Event down; + down.type = render::Event::Type::MouseDown; + down.button = 1; + down.x = rect.x + rect.width * .5f; + down.y = rect.y + rect.height * .5f; + auto up = down; + up.type = render::Event::Type::MouseUp; + launcher.frame({down, up}); +} +void text(editor::ProjectLauncher& launcher, const std::string& id, const std::string& value, + bool commit = true) { + click(launcher, id); + render::Event input; + input.type = render::Event::Type::TextInput; + input.text = value; + std::vector events{key("A", true), input}; + if (commit) + events.push_back(key("Return")); + launcher.frame(events); +} +} // namespace +int main() { + const auto root = std::filesystem::temp_directory_path() / ("faset-launcher-ui-" + new_id()); + try { + const auto existing = root / "Existing project"; + atomic_write_json(existing / "project.faset.json", {{"format", "faset.project"}, + {"version", 1}, + {"name", "Existing project"}, + {"dimension", 2}}); + const auto recents = root / "recent-projects.json"; + atomic_write_json(recents, Json::array({existing.string(), (root / "Missing").string(), + existing.string()})); + render::Renderer renderer({1100, 720, "Project launcher acceptance", true, true}); + { + editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, {}, recents); + launcher.frame({}); + check(launcher.widgets().find("launcher-create")->selected, + "No initial path starts Create"); + check(launcher.widgets().find("launcher-recent-0") && + !launcher.widgets().find("launcher-recent-1"), + "Recent list includes real valid unique projects only"); + text(launcher, "launcher-name", "Тестовый проект"); + text(launcher, "launcher-path", existing.string()); + click(launcher, "launcher-submit"); + check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(), + "Create must reject nonempty existing project"); + text(launcher, "launcher-path", (root / "New Game").string()); + click(launcher, "launcher-2d"); + check(launcher.widgets().find("launcher-2d")->selected, "2D project selection"); + renderer.render(launcher.snapshot()); + renderer.capture(root / "launcher-create.ppm"); + launcher.frame({key("Return", true)}); + check(launcher.selection() && launcher.selection()->create && + launcher.selection()->dimension == 2 && + launcher.selection()->name == "Тестовый проект", + "Create through keyboard returns typed selection"); + check(!std::filesystem::exists(root / "New Game"), + "Launcher does not create a partial project before Session scaffold"); + } + { + editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, root / "Missing", + recents); + launcher.frame({}); + click(launcher, "launcher-submit"); + check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(), + "Open validates missing directory"); + click(launcher, "launcher-recent-0"); + check(launcher.widgets().find("launcher-path")->text == existing.string(), + "Recent selection fills actual path"); + click(launcher, "launcher-browse"); + check(launcher.widgets().find("launcher-browser")->visible, + "Native retained directory browser opens"); + text(launcher, "browser-path", (root / "Absent folder").string()); + launcher.frame({key("Return", true)}); + check(launcher.widgets().find("launcher-browser")->visible && + !launcher.widgets().find("browser-choose")->enabled, + "Invalid typed directory cannot silently select prior directory"); + text(launcher, "browser-path", existing.string()); + click(launcher, "browser-up"); + check(launcher.widgets().find("browser-path")->text == root.string(), + "Folder browser Up navigation"); + click(launcher, "browser-entry-0"); + check(launcher.widgets().find("browser-path")->text == existing.string(), + "Directory list navigation"); + renderer.render(launcher.snapshot()); + renderer.capture(root / "launcher-browser.ppm"); + click(launcher, "browser-choose"); + check(!launcher.widgets().find("launcher-browser")->visible, + "Choose closes directory browser"); + launcher.frame({key("Return", true)}); + check(launcher.selection() && !launcher.selection()->create && + launcher.selection()->path == existing && + launcher.selection()->dimension == 2, + "Open reads project metadata"); + } + { + const auto corrupt = root / "Corrupt"; + atomic_write_json(corrupt / "project.faset.json", + {{"format", "faset.project"}, {"version", 9}, {"name", "Future"}}); + editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, corrupt, recents); + launcher.frame({}); + click(launcher, "launcher-submit"); + check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(), + "Unsupported project version is rejected"); + launcher.frame({key("Escape")}); + check(launcher.cancelled(), "Escape cancels launcher"); + } + check(renderer.stats().validation_errors == 0, "Launcher Vulkan validation"); + std::cout + << "Launcher Unicode/create/open/validation/recents/directory browser/keyboard passed. " + << root << '\n'; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << "\nRetained: " << root << '\n'; + return 1; + } +} diff --git a/tests/editor_ui_project_settings.cpp b/tests/editor_ui_project_settings.cpp new file mode 100644 index 0000000..bc1c6d9 --- /dev/null +++ b/tests/editor_ui_project_settings.cpp @@ -0,0 +1,135 @@ +#include +#include +#include +using namespace faset; +namespace { +void check(bool value, const std::string& message) { + if (!value) + throw std::runtime_error(message); +} +render::Event key(std::string name, bool control = false) { + render::Event e; + e.type = render::Event::Type::KeyDown; + e.key = std::move(name); + e.control = control; + return e; +} +void click(editor::EditorUI& ui, const std::string& id) { + const auto* w = ui.widgets().find(id); + check(w, "Missing widget: " + id); + const auto r = w->rect.intersection(w->clip); + check(r.width > 0 && r.height > 0, "Hidden widget: " + id); + render::Event down; + down.type = render::Event::Type::MouseDown; + down.button = 1; + down.x = r.x + r.width * .5f; + down.y = r.y + r.height * .5f; + auto up = down; + up.type = render::Event::Type::MouseUp; + ui.frame({down, up}); +} +void text(editor::EditorUI& ui, const std::string& id, const std::string& value) { + click(ui, id); + render::Event input; + input.type = render::Event::Type::TextInput; + input.text = value; + ui.frame({key("A", true), input, key("Return")}); +} +void open(editor::EditorUI& ui) { + click(ui, "menu-File"); + click(ui, "project-settings"); +} +} // namespace +int main() { + const auto root = + std::filesystem::temp_directory_path() / ("faset-project-settings-ui-" + new_id()); + try { + const auto project_file = root / "project.faset.json"; + atomic_write_json(project_file, {{"format", "faset.project"}, + {"version", 1}, + {"id", new_id()}, + {"name", "Original project"}, + {"dimension", 3}, + {"custom_metadata", {{"preserve", true}}}}); + editor::Session session({root, FASET_TEST_ENGINE, root}); + render::Renderer renderer({1280, 800, "Project settings acceptance", true, true}); + editor::EditorUI ui(session, renderer, + std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", + std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json"); + ui.frame({}); + click(ui, "add-cube"); + session.authoring().save(ui.current_document(), "Scenes/Main.scene.json"); + const auto other = session.authoring().create("Other", 2); + session.authoring().save(other.at("id"), "Scenes/Other.scene.json"); + ui.frame({}); + const auto document_before = session.authoring().query(ui.current_document()); + const auto project_before = read_json(project_file); + open(ui); + text(ui, "project-settings-name", "Unsaved project name"); + click(ui, "project-settings-cancel"); + check(read_json(project_file) == project_before, + "Cancel discards only form draft without writing project"); + open(ui); + check(ui.widgets().find("project-settings-name")->text == "Original project", + "Opening settings reloads current saved values"); + text(ui, "project-settings-name", "Новый проект"); + click(ui, "project-settings-2d"); + click(ui, "project-scene-choice-1"); + check(ui.widgets().find("project-settings-start")->text == "Scenes/Other.scene.json", + "Saved scene chooser sets project-relative path"); + click(ui, "project-settings-save"); + auto saved = read_json(project_file); + check(saved["name"] == "Новый проект" && saved["dimension"] == 2 && + saved["start_scene"] == "Scenes/Other.scene.json", + "Explicit Save project persists typed settings"); + check(saved["custom_metadata"] == project_before["custom_metadata"] && + saved["id"] == project_before["id"], + "Project settings preserve unknown metadata and project identity"); + check(session.authoring().query(ui.current_document()) == document_before, + "Project settings do not change current document, dimension, revision or Undo"); + open(ui); + text(ui, "project-settings-start", "Scenes/Missing.scene.json"); + click(ui, "project-settings-save"); + check(ui.widgets().find("project-settings-panel")->visible && + !ui.widgets().find("project-settings-error")->text.empty() && + read_json(project_file) == saved, + "Invalid start scene keeps saved project and form open"); + click(ui, "project-settings-reload"); + text(ui, "project-settings-name", "Local draft"); + const auto external = session.commands().call("faset_project_settings_get", Json::object()); + session.commands().call( + "faset_project_settings_set", + {{"revision", external.at("revision")}, {"settings", {{"name", "External edit"}}}}); + click(ui, "project-settings-save"); + check(ui.widgets().find("project-settings-panel")->visible && + read_json(project_file)["name"] == "External edit" && + ui.widgets().find("project-settings-name")->text == "Local draft", + "Revision conflict preserves external project and local draft"); + check(ui.widgets().find("project-settings-error")->text.find("reload") != std::string::npos, + "Conflict directs user to reload"); + click(ui, "project-settings-reload"); + check(ui.widgets().find("project-settings-name")->text == "External edit", + "Explicit Reload saved replaces stale form draft"); + click(ui, "project-settings-3d"); + click(ui, "project-scene-choice-0"); + click(ui, "project-settings-save"); + check(read_json(project_file)["start_scene"] == "Scenes/Main.scene.json", + "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"); + open(ui); + renderer.render(ui.snapshot()); + renderer.capture(root / "project-settings.ppm"); + ui.frame({key("Escape")}); + check(!ui.widgets().find("project-settings-panel")->visible, + "Escape closes project settings without saving"); + check(renderer.stats().validation_errors == 0, "Project settings Vulkan validation"); + std::cout << "Project settings name/type/start-scene Save/Cancel/Reload/conflict, metadata " + "preservation and separate scene Undo passed. " + << root << '\n'; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << "\nRetained: " << root << '\n'; + return 1; + } +} diff --git a/tests/editor_ui_reload.cpp b/tests/editor_ui_reload.cpp new file mode 100644 index 0000000..a982269 --- /dev/null +++ b/tests/editor_ui_reload.cpp @@ -0,0 +1,127 @@ +#include +#include +#include +#include +using namespace faset; +namespace { +void check(bool value, const std::string& message) { + if (!value) + throw std::runtime_error(message); +} +render::Event key(std::string name, bool control = false) { + render::Event e; + e.type = render::Event::Type::KeyDown; + e.key = std::move(name); + e.control = control; + return e; +} +void click(editor::EditorUI& ui, const std::string& id) { + const auto* w = ui.widgets().find(id); + check(w, "Missing widget: " + id); + const auto r = w->rect.intersection(w->clip); + check(r.width > 0 && r.height > 0, "Hidden widget: " + id); + render::Event down; + down.type = render::Event::Type::MouseDown; + down.button = 1; + down.x = r.x + r.width * .5f; + down.y = r.y + r.height * .5f; + auto up = down; + up.type = render::Event::Type::MouseUp; + ui.frame({down, up}); +} +void poll(editor::EditorUI& ui) { + std::this_thread::sleep_for(std::chrono::milliseconds(550)); + ui.frame({}); +} +} // namespace +int main() { + const auto root = std::filesystem::temp_directory_path() / ("faset-ui-reload-" + new_id()); + try { + const auto styles = root / "styles/dark.json", + layout_path = root / "styles/editor-layout.json"; + auto theme = read_json(std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json"); + auto layout = + read_json(std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/editor-layout.json"); + atomic_write_json(styles, theme); + atomic_write_json(layout_path, layout); + editor::Session session({root / "project", FASET_TEST_ENGINE, root}); + render::Renderer renderer({1280, 800, "Style reload acceptance", true, true}); + editor::EditorUI ui(session, renderer, + std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", + styles); + ui.frame({}); + click(ui, "add-cube"); + click(ui, "object-name"); + render::Event input; + input.type = render::Event::Type::TextInput; + input.text = "Unfinished name"; + ui.frame({key("A", true), input}); + const auto revision = session.authoring().query(ui.current_document()).at("revision"); + const auto width = ui.widgets().find("scene_panel")->rect.width; + renderer.render(ui.snapshot()); + const auto pixels_before = renderer.pixels(); + theme["surface"] = {.18, .24, .12, 1}; + layout["root"]["children"][2]["children"][0]["layout"]["width"] = 270; + atomic_write_json(styles, theme); + atomic_write_json(layout_path, layout); + poll(ui); + check(ui.widgets().find("scene_panel")->rect.width == 270 && width != 270, + "Valid layout edit changes live panel geometry"); + check(ui.widgets().focused_id() == "object-name" && + ui.widgets().find("object-name")->text == "Unfinished name", + "Valid reload preserves focus and unfinished text"); + renderer.render(ui.snapshot()); + const auto pixels_after = renderer.pixels(); + const auto pixel = (500 * renderer.width() + 10) * 4; + check(pixels_before.at(pixel) != pixels_after.at(pixel) || + pixels_before.at(pixel + 1) != pixels_after.at(pixel + 1), + "Valid theme edit changes actual Vulkan pixels"); + check(session.authoring().query(ui.current_document()).at("revision") == revision, + "Presentation reload does not author the scene"); + const auto working_theme = ui.widgets().theme().to_json(); + const auto working_menubar = ui.widgets().find("menubar")->layout.height; + auto bad_layout = layout; + bad_layout["root"]["children"][0]["layout"]["height"] = 68; + bad_layout["root"]["children"].back()["text"] = 7; + theme["surface"] = {.3, .08, .2, 1}; + atomic_write_json(styles, theme); + atomic_write_json(layout_path, bad_layout); + poll(ui); + check(ui.widgets().theme().to_json() == working_theme && + ui.widgets().find("menubar")->layout.height == working_menubar, + "Invalid layout rejects both candidate files atomically"); + check(ui.widgets().find("scene_panel")->rect.width == 270 && + ui.widgets().focused_id() == "object-name" && + ui.widgets().find("object-name")->text == "Unfinished name", + "Invalid reload retains last good layout and editing state"); + const auto log_count = session.logs().size(); + poll(ui); + check(session.logs().size() == log_count, + "Unchanged invalid file does not flood editor logs"); + atomic_write_json(layout_path, layout); + poll(ui); + check(ui.widgets().theme().surface[0] == .3f, + "Fixing invalid layout publishes pending theme candidate"); + atomic_write(styles, "{ broken json"); + poll(ui); + check(ui.widgets().theme().surface[0] == .3f, + "Partially written JSON keeps last working theme"); + atomic_write_json(styles, theme); + poll(ui); + ui.frame({key("Return")}); + const auto saved = session.authoring().query(ui.current_document()); + check(saved.at("revision") == revision.get() + 1 && + saved["scene"]["entities"][0]["name"] == "Unfinished name", + "Retained callback commits preserved edit once after failed and valid reloads"); + renderer.render(ui.snapshot()); + renderer.capture(root / "reloaded.ppm"); + check(renderer.stats().validation_errors == 0, "Reload Vulkan validation"); + std::cout << "Theme/layout live reload pixels+geometry, atomic rejection, focus+draft " + "preservation, callback and diagnostic dedup passed. " + << root << '\n'; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << "\nRetained: " << root << '\n'; + return 1; + } +} diff --git a/tests/editor_ui_templates.cpp b/tests/editor_ui_templates.cpp new file mode 100644 index 0000000..8fcc8b5 --- /dev/null +++ b/tests/editor_ui_templates.cpp @@ -0,0 +1,227 @@ +#include +#include +#include +#include +using namespace faset; +namespace { +void check(bool value, const std::string& message) { + if (!value) + throw std::runtime_error(message); +} +render::Event key(std::string name, bool control = false) { + render::Event e; + e.type = render::Event::Type::KeyDown; + e.key = std::move(name); + e.control = control; + return e; +} +void click(editor::EditorUI& editor, const std::string& id) { + for (int attempts = 0; attempts < 30; ++attempts) { + auto* widget = editor.widgets().find(id); + check(widget, "Missing widget: " + id); + const auto visible = widget->rect.intersection(widget->clip); + if (visible.width > 2 && visible.height > 2) { + render::Event down; + down.type = render::Event::Type::MouseDown; + down.button = 1; + down.x = visible.x + visible.width * .5f; + down.y = visible.y + visible.height * .5f; + auto up = down; + up.type = render::Event::Type::MouseUp; + editor.frame({down, up}); + return; + } + auto* parent = widget->parent; + while (parent && !parent->layout.scroll) + parent = parent->parent; + check(parent, "Widget is hidden: " + id); + render::Event move; + move.type = render::Event::Type::MouseMove; + move.x = parent->rect.x + 20; + move.y = parent->rect.y + 20; + render::Event wheel; + wheel.type = render::Event::Type::Wheel; + wheel.y = widget->rect.y < parent->rect.y ? 2 : -2; + editor.frame({move, wheel}); + } + throw std::runtime_error("Cannot scroll to widget: " + id); +} +void text(editor::EditorUI& editor, const std::string& id, const std::string& value) { + click(editor, id); + render::Event input; + input.type = render::Event::Type::TextInput; + input.text = value; + editor.frame({key("A", true), input, key("Return")}); +} +Json query(editor::Session& session, editor::EditorUI& editor) { + return session.authoring().query(editor.current_document()); +} +Json leaf(editor::Session& session, const std::string& document, std::size_t depth) { + const auto resolved = session.commands().resolved_scene(document); + for (const auto& e : resolved.at("scene").at("entities")) + if (e.at("origin").at("path").size() == depth) + return e; + throw std::runtime_error("Resolved template leaf missing"); +} +std::string position_field(const Json& object) { + return "field-" + object["components"][0]["id"].get() + "-position-0"; +} +float position(const Json& object) { + return object["components"][0]["fields"]["position"][0].get(); +} +} // namespace +int main() { + const auto root = std::filesystem::temp_directory_path() / ("faset-template-ui-" + new_id()); + try { + editor::Session session({root, FASET_TEST_ENGINE, root}); + render::Renderer renderer({1280, 900, "Template workflow test", true, true}); + editor::EditorUI ui(session, renderer, + std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", + std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json"); + ui.frame({}); + const auto main = ui.current_document(); + click(ui, "add-cube"); + text(ui, "object-name", "Door"); + const auto local = ui.selected_entity(); + click(ui, "menu-Scene"); + text(ui, "template-path", "Assets/Templates/Door.scene.json"); + click(ui, "save-template"); + check(std::filesystem::exists(root / "Assets/Templates/Door.scene.json"), + "Manual source template creation"); + for (int i = 0; i < 2; ++i) { + click(ui, "menu-Scene"); + click(ui, "instance-template"); + } + check(query(session, ui)["scene"]["instances"].size() == 2, + "Two instances from manual Scene menu"); + ui.select_entity(local); + ui.frame({}); + ui.frame({key("Delete")}); + check(query(session, ui)["scene"]["entities"].empty(), + "Delete local original independently of template"); + click(ui, "menu-File"); + click(ui, "new-3d"); + const auto outer_document = ui.current_document(); + click(ui, "menu-Scene"); + click(ui, "instance-template"); + click(ui, "menu-File"); + text(ui, "save-path", "Assets/Templates/Outer.scene.json"); + click(ui, "save-as-button"); + click(ui, "back-document"); + check(ui.current_document() == main, "Open source/back document navigation"); + click(ui, "menu-Scene"); + text(ui, "template-path", "Assets/Templates/Outer.scene.json"); + click(ui, "instance-template"); + auto nested = leaf(session, main, 2); + const auto nested_id = nested.at("id").get(); + click(ui, "entity-" + nested_id); + text(ui, position_field(nested), "5"); + auto state = query(session, ui); + const auto& record = state["scene"]["instances"].back()["overrides"][0]; + check(record["address"]["path"].size() == 1, "Nested override uses relative instance path"); + check(position(leaf(session, main, 2)) == 5, "Nested Inspector override"); + const auto revert = + "field-" + nested["components"][0]["id"].get() + "-position-revert"; + check(ui.widgets().find(revert)->enabled, "Override provenance enables Revert"); + click(ui, revert); + check(position(leaf(session, main, 2)) == 0, "Revert reads source value"); + click(ui, "object-open-source"); + check(ui.current_document() != main && ui.current_document() != outer_document, + "Nested Open source reaches inner source"); + text(ui, "object-name", "Renamed Door"); + auto source = query(session, ui)["scene"]["entities"][0]; + text(ui, position_field(source), "2"); + click(ui, "back-document"); + check(ui.current_document() == main, "Back returns to instance scene"); + nested = leaf(session, main, 2); + check(nested["id"] == nested_id && nested["name"] == "Renamed Door" && + position(nested) == 2, + "Dirty open source propagates while identity survives rename"); + click(ui, "entity-" + nested_id); + ui.frame({key("Delete")}); + state = query(session, ui); + const auto top = state["scene"]["instances"].back()["id"]; + check(state["scene"]["instances"].back()["suppressed"].size() == 1, + "Delete inherited object records suppression"); + click(ui, "instance-" + Json::array({top}).dump()); + click(ui, "instance-restore-0"); + check(leaf(session, main, 2)["id"] == nested_id, "Restore suppression keeps identity"); + click(ui, "instance-add-local"); + const auto additions = session.commands().resolved_scene(main).at("scene").at("entities"); + std::string addition_id; + for (const auto& object : additions) + if (object.at("origin").value("local", false) && + object.at("origin").at("path").size() == 1) + addition_id = object.at("id"); + check(!addition_id.empty(), "Instance-local addition is resolved"); + click(ui, "entity-" + addition_id); + text(ui, "object-name", "Local lamp"); + click(ui, "add-component"); + click(ui, "component-choice-faset.mesh"); + const auto addition = query(session, ui)["scene"]["instances"].back()["additions"][0]; + check(addition["name"] == "Local lamp" && addition["components"].size() == 2 && + addition["components"].back()["type"] == "faset.mesh", + "Local addition supports manual rename and component creation"); + click(ui, "simulation-settings"); + const auto before = query(session, ui)["revision"].get(); + text(ui, "simulation-tick-rate", "120"); + check(std::abs(query(session, ui)["scene"]["simulation"]["fixed_delta"].get() - + 1.0 / 120) < 1e-8, + "Simulation UI converts Hz to seconds"); + check(query(session, ui)["revision"] == before + 1, + "Simulation field is one Undo transaction"); + text(ui, "simulation-gravity-1", "-3"); + check(query(session, ui)["scene"]["simulation"]["gravity"][1] == -3, + "Simulation gravity field"); + click(ui, "simulation-close"); + click(ui, "undo"); + check(query(session, ui)["scene"]["simulation"]["gravity"][1] == -9.81, + "Simulation Undo restores gravity"); + + nested = leaf(session, main, 2); + click(ui, "entity-" + nested_id); + text(ui, position_field(nested), "7"); + click(ui, "object-open-source"); + source = query(session, ui)["scene"]["entities"][0]; + click(ui, "component-remove-" + source["components"][0]["id"].get()); + click(ui, "back-document"); + const auto conflicted = session.commands().resolved_scene(main); + check(!conflicted.at("conflicts").empty() && + query(session, ui)["scene"]["instances"].back()["overrides"].size() == 1, + "Source deletion retains unapplied override as an explicit conflict"); + click(ui, "tab-conflicts"); + click(ui, "conflict-0-discard"); + check(query(session, ui)["scene"]["instances"].back()["overrides"].empty(), + "Conflict discard is an explicit authoring action"); + click(ui, "undo"); + check(query(session, ui)["scene"]["instances"].back()["overrides"].size() == 1, + "Conflict discard is undoable"); + + auto opaque = authoring::make_entity(session.authoring().schemas(), "Future transform"); + opaque["components"][0]["version"] = 2; + opaque["components"][0]["fields"] = {{"future", "untouched"}}; + session.authoring().transact(main, query(session, ui).at("revision"), + Json::array({{{"op", "entity.create"}, {"entity", opaque}}})); + ui.frame({}); + click(ui, "entity-" + opaque.at("id").get()); + const auto opaque_component = opaque["components"][0]["id"].get(); + const auto* raw = ui.widgets().find("opaque-fields-" + opaque_component); + check(raw && !raw->enabled && raw->text == opaque["components"][0]["fields"].dump(), + "Unsupported component version stays opaque and read-only"); + check(!ui.widgets().find("field-" + opaque_component + "-future"), + "Current-schema Inspector does not interpret future component fields"); + check(query(session, ui)["scene"]["entities"].back() == opaque, + "Opaque component data survives Inspector and viewport updates"); + renderer.render(ui.snapshot()); + renderer.capture(root / "templates.ppm"); + check(renderer.stats().validation_errors == 0, "Vulkan validation"); + std::cout << "Manual templates: create/two instances/nesting/overrides/Revert/source " + "rename/suppression restore/local additions/conflicts/opaque versions; " + "simulation UI+Undo passed. " + << root << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << "\nRetained: " << root << '\n'; + return 1; + } +} diff --git a/tests/editor_ui_tests.cpp b/tests/editor_ui_tests.cpp index 5452480..fa10671 100644 --- a/tests/editor_ui_tests.cpp +++ b/tests/editor_ui_tests.cpp @@ -1,7 +1,10 @@ +#include "assets_image_fixtures.hpp" #include #include #include +#include #include +#include using namespace faset; void check(bool value, const char* message) { if (!value) @@ -175,6 +178,75 @@ int main() { "Plugin panel action must author Beacon through Commands"); click(ui, "tab-assets"); #endif + std::filesystem::create_directories(root / "Assets"); + const auto image_path = root / "Assets/TwoPixels.png"; + { + std::ofstream stream(image_path, std::ios::binary); + const auto& png = faset::test_images::png_red_green; + stream.write(reinterpret_cast(png.data()), + static_cast(png.size())); + } + const auto import_job = + session.commands() + .call("faset_import", + {{"path", "Assets/TwoPixels.png"}, {"settings", {{"pixels_per_unit", 1.0}}}}) + .at("job") + .get(); + Json imported; + for (int wait = 0; wait < 500; ++wait) { + session.poll(); + imported = session.commands().call("faset_job", {{"id", import_job}}); + if (imported.at("state") != "queued" && imported.at("state") != "running") + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + check(imported.at("state") == "succeeded", "Actual asynchronous PNG importer job"); + click(ui, "asset-refresh"); + ui.frame({}); + const auto image_id = imported.at("result").at("asset_id").get(); + auto* image_row = ui.widgets().find("asset-" + image_id); + check(image_row, "Imported image in asset browser"); + const auto asset_rect = image_row->rect.intersection(image_row->clip); + const auto viewport_rect = ui.widgets().find("viewport")->rect; + down.x = asset_rect.x + 100; + down.y = asset_rect.y + 12; + move = down; + move.type = render::Event::Type::MouseMove; + move.x = viewport_rect.x + viewport_rect.width * .5f; + move.y = viewport_rect.y + viewport_rect.height * .5f; + up = move; + up.type = render::Event::Type::MouseUp; + ui.frame({down, move, up}); + state = session.authoring().query(ui.current_document()); + const auto& sprite = state["scene"]["entities"].back()["components"].back(); + check(sprite["type"] == "faset.sprite" && sprite["fields"]["texture"] == image_id, + "Image drag/drop must author Sprite with AssetId"); + check(sprite["fields"]["size"] == Json::array({2.0, 1.0}), + "Image drag/drop preserves dimensions through pixels_per_unit"); + check(!ui.snapshot().sprites.empty() && ui.snapshot().sprites.back().texture && + ui.snapshot().sprites.back().texture->width == 2, + "PNG sprite must decode into actual render texture"); + const auto before_switch = state.at("revision"); + click(ui, "menu-File"); + click(ui, "open-project"); + check(ui.widgets().find("project-switch-dialog")->visible && !ui.project_switch_requested(), + "Dirty project switch requires an explicit recovery warning action"); + ui.frame({key("Delete")}); + check(session.authoring().query(ui.current_document()).at("revision") == before_switch, + "Switch dialog captures editor shortcuts"); + click(ui, "project-switch-cancel"); + check(!ui.project_switch_requested(), "Cancelling project switch keeps editor open"); + ui.set_project_switch_enabled(false); + click(ui, "menu-File"); + check(!ui.widgets().find("open-project")->enabled, + "Project switch disabled while an MCP client owns the session"); + click(ui, "open-project"); + check(!ui.project_switch_requested(), "Disabled project switch cannot request exit"); + ui.set_project_switch_enabled(true); + ui.frame({}); + click(ui, "open-project"); + click(ui, "project-switch-continue"); + check(ui.project_switch_requested(), "Confirmed project switch is exposed to application"); renderer.render(ui.snapshot()); renderer.capture(root / "editor-ui.ppm"); check(renderer.stats().validation_errors == 0, "Vulkan validation errors"); diff --git a/tests/mcp_stdio_test.py b/tests/mcp_stdio_test.py index f7cc9cb..053d850 100644 --- a/tests/mcp_stdio_test.py +++ b/tests/mcp_stdio_test.py @@ -40,6 +40,7 @@ def run(executable, project): assert initialized["result"]["serverInfo"]["name"] == "faset-editor" process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n') process.stdin.flush() + assert call("faset_schema_status")["stale"] names = {tool["name"] for tool in request("tools/list")["result"]["tools"]} assert {"faset_scene_edit", "faset_export", "faset_job_cancel", "faset_plugins"} <= names assert "faset_runtime_query" not in names and "faset_editor_capture" not in names @@ -78,7 +79,38 @@ def run(executable, project): assert code == 0, errors +def disconnected_output(executable, project): + process = subprocess.Popen([executable, "--project", str(project), "--mcp"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8") + # Keep input open: output failure itself must close the transport. Before + # the fix this terminated the POSIX Editor with SIGPIPE (return code -13). + process.stdout.close() + try: + process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18"}}) + "\n") + process.stdin.flush() + assert process.wait(timeout=20) == 0, process.stderr.read() + finally: + if process.poll() is None: + process.kill() + process.wait() + process.stdin.close() + + +def eof_tail(executable, project): + # A final buffered request must still receive its reply when input closes. + message = json.dumps({"jsonrpc": "2.0", "id": "tail", "method": "ping"}) + result = subprocess.run([executable, "--project", str(project), "--mcp"], + input=message, capture_output=True, text=True, + encoding="utf-8", timeout=20) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["id"] == "tail", result.stdout + + with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary: run(sys.argv[1], pathlib.Path(temporary)) run(sys.argv[1], pathlib.Path(temporary)) -print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen and process shutdown passed") + disconnected_output(sys.argv[1], pathlib.Path(temporary)) + eof_tail(sys.argv[1], pathlib.Path(temporary)) +print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen, broken output pipe and EOF shutdown passed") diff --git a/tests/render_reload_tests.cpp b/tests/render_reload_tests.cpp new file mode 100644 index 0000000..ca9523b --- /dev/null +++ b/tests/render_reload_tests.cpp @@ -0,0 +1,153 @@ +#include +#include +#include +#include +#include +#include +#include +#include +using namespace faset; +namespace fs = std::filesystem; +namespace { +void require(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +int compile(const fs::path& source, const fs::path& output) { + Process process( + {{FASET_PYTHON_EXECUTABLE, FASET_SHADER_COMPILE_TOOL, "--compiler", FASET_TEST_SLANGC, + "--source", source.string(), "--entry", "fragmentMain", "--output", output.string()}, + {}, + {}}); + std::string diagnostics; + while (true) { + auto result = process.poll(); + diagnostics += result.output; + if (!result.running) { + if (result.exit_code != 0) + require(diagnostics.find("intentional_shader_compile_failure") != std::string::npos, + "Slang failure must preserve its useful compiler diagnostic"); + return result.exit_code.value_or(1); + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +} +} // namespace +int main() { + const auto temporary = fs::temp_directory_path() / ("faset-shader-reload-" + new_id()); + struct Cleanup { + fs::path path; + ~Cleanup() { + std::error_code ignored; + fs::remove_all(path, ignored); + } + } cleanup{temporary}; + try { + const auto bundle = temporary / "shaders"; + fs::create_directories(bundle); + for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"}) + for (const auto* extension : {".spv", ".reflection.json"}) { + const auto name = std::string(entry) + extension; + fs::copy_file(fs::path(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name); + } + const auto source = temporary / "reload.slang"; + const auto original_source = read_text(FASET_TEST_SHADER_SOURCE); + const auto original_spirv = read_text(bundle / "fragmentMain.spv"); + 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); + render::RendererConfig configuration; + configuration.width = configuration.height = 64; + configuration.headless = true; + configuration.validation = true; + configuration.shader_directory = bundle; + render::Renderer renderer(configuration); + render::Snapshot scene; + scene.ui_quads.push_back({0, 0, 64, 64, {1, .8f, .4f, 1}}); + renderer.render(scene); + auto expected = renderer.pixels(); + require(renderer.stats().gpu_allocated_bytes > 1024 * 1024 && + renderer.stats().texture_count >= 1, + "Frame profile reports live Vulkan allocations and textures"); + auto restore = [&] { + atomic_write(bundle / "fragmentMain.spv", original_spirv); + atomic_write(bundle / "fragmentMain.reflection.json", original_reflection); + }; + auto retained = [&] { + std::string error; + require(!renderer.reload_shaders(error) && !error.empty(), + "Unsafe shader reload must fail with a diagnostic"); + renderer.render(scene); + require(renderer.pixels() == expected, + "Rejected shader reload must retain working pixels"); + require(renderer.stats().validation_errors == 0, + "Rejected bytecode must not reach Vulkan validation"); + }; + atomic_write(bundle / "fragmentMain.spv", "damaged bytecode"); + retained(); + restore(); + auto malformed = original_spirv; + for (int i = 0; i < 4; ++i) + malformed[20 + i] = 0; // zero-word SPIR-V instruction + auto metadata = Json::parse(original_reflection); + metadata["spirv_sha256"] = sha256(malformed); + atomic_write(bundle / "fragmentMain.spv", malformed); + atomic_write_json(bundle / "fragmentMain.reflection.json", metadata); + retained(); + restore(); + auto incompatible = original_source; + auto 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)]]"); + atomic_write(source, incompatible); + require(compile(source, bundle) == 0, "Compile real incompatible descriptor layout"); + require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") != + original_fingerprint, + "Descriptor edit changes normalized layout fingerprint"); + retained(); + restore(); + incompatible = original_source; + at = incompatible.find("column_major float4x4"); + require(at != std::string::npos, "Shader matrix fixture exists"); + incompatible.replace(at, std::string("column_major float4x4").size(), "row_major float4x4"); + atomic_write(source, incompatible); + require(compile(source, bundle) == 0, "Compile real incompatible matrix storage"); + retained(); + restore(); + auto compatible = original_source; + at = compatible.find(" float4 sampled ="); + require(at != std::string::npos, "Shader fragment fixture exists"); + compatible.insert(at, " v.color.rgb *= 0.5;\n"); + atomic_write(source, compatible); + require(compile(source, bundle) == 0, "Compile real compatible shader edit"); + require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") == + original_fingerprint, + "Source-only behavior edit preserves normalized layout fingerprint"); + std::string error; + require(renderer.reload_shaders(error), "Compatible shader edit reloads successfully"); + renderer.render(scene); + const auto changed = renderer.pixels(); + require(changed[0] + 50 < expected[0], "Compatible reload changes actual rendered pixels"); + expected = changed; + const auto last_spirv = read_text(bundle / "fragmentMain.spv"); + const auto last_reflection = read_text(bundle / "fragmentMain.reflection.json"); + atomic_write(source, compatible + "\n#error intentional_shader_compile_failure\n"); + require(compile(source, bundle) != 0, "Real Slang compile failure is reported"); + require(read_text(bundle / "fragmentMain.spv") == last_spirv && + read_text(bundle / "fragmentMain.reflection.json") == last_reflection, + "Compile failure preserves both published shader artifacts"); + require(renderer.reload_shaders(error), + "Last successfully compiled artifacts remain reloadable"); + renderer.render(scene); + require(renderer.pixels() == expected, "Compile failure preserves last good rendering"); + require(renderer.stats().validation_errors == 0, + "Shader reload regression has no Vulkan validation errors"); + std::cout << "Normalized reflection, malformed SPIR-V, incompatible descriptors/matrices, " + "real compile failure and compatible pixel reload passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/render_tests.cpp b/tests/render_tests.cpp index 389c038..1e276ec 100644 --- a/tests/render_tests.cpp +++ b/tests/render_tests.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -122,6 +123,28 @@ int main(int argc, char** argv) { renderer.render(scene); require(renderer.width() == 400 && renderer.height() == 300, "Render target resize"); require(renderer.stats().validation_errors == 0, "Resize validation error"); + if (visible) { + int window_count{}; + auto windows = SDL_GetWindows(&window_count); + require(windows && window_count == 1, "Visible test owns exactly one SDL window"); + auto* window = windows[0]; + SDL_free(windows); + require(SDL_HideWindow(window), "Hide the test window"); + const auto before = renderer.stats().frame; + // Exhaust any compositor buffers without an application event poll. Captures + // must still render fresh content when presentation is unavailable. + for (int frame = 0; frame < 12; ++frame) { + scene.ui_quads[0].color = frame % 2 ? Color{0, 1, 0, 1} : Color{1, 0, 0, 1}; + renderer.render(scene); + auto capture = renderer.pixels(); + const auto at = (10 * renderer.width() + 10) * 4; + require(capture.at(at + (frame % 2 ? 1 : 0)) > 240, + "Hidden-window capture must contain the latest frame"); + } + require(renderer.stats().frame == before + 12, + "Hidden-window capture must progress without swapchain images"); + require(renderer.stats().validation_errors == 0, "Hidden-window validation error"); + } std::cout << "Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on " << renderer.stats().device << '\n'; } catch (const std::exception& e) { diff --git a/tests/render_window_tests.cpp b/tests/render_window_tests.cpp new file mode 100644 index 0000000..47da99c --- /dev/null +++ b/tests/render_window_tests.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace faset::render; +using Clock = std::chrono::steady_clock; +void require(bool condition, const char* message) { + if (!condition) + throw std::runtime_error(message); +} +struct WindowEvents { + SDL_WindowID id{}; + unsigned minimized{}, restored{}, resized{}, focus_lost{}; + static bool watch(void* context, SDL_Event* event) { + auto& state = *static_cast(context); + if (event->window.windowID == state.id) { + if (event->type == SDL_EVENT_WINDOW_MINIMIZED) + ++state.minimized; + if (event->type == SDL_EVENT_WINDOW_RESTORED) + ++state.restored; + if (event->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) + ++state.resized; + if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) + ++state.focus_lost; + } + return true; + } +}; +} // namespace + +int main(int argc, char** argv) { + // Some window systems can block inside native window calls. Terminate only this + // fixture if that happens, so an acceptance test cannot strand its own process. + std::jthread watchdog([](std::stop_token stop) { + for (int i = 0; i < 150 && !stop.stop_requested(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + if (!stop.stop_requested()) { + std::cerr << "Window fixture exceeded 30 seconds; terminating its own process\n"; + std::_Exit(70); + } + }); + try { + Renderer renderer({320, 240, "Faset owned window lifecycle test", false, true}); + int count{}; + auto windows = SDL_GetWindows(&count); + require(windows && count == 1, "Fixture must own exactly one SDL window"); + auto* window = windows[0]; + SDL_free(windows); + WindowEvents events{SDL_GetWindowID(window)}; + require(SDL_AddEventWatch(WindowEvents::watch, &events), "Register lifecycle event watch"); + struct WatchGuard { + WindowEvents& events; + ~WatchGuard() { + SDL_RemoveEventWatch(WindowEvents::watch, &events); + } + } watch{events}; + std::cout << "driver=" << SDL_GetCurrentVideoDriver() << " stage=created\n" << std::flush; + Snapshot scene; + scene.ui_quads.push_back({0, 0, 4096, 4096, {1, 0, 0, 1}}); + unsigned frame{}; + auto draw = [&] { + renderer.poll_events(); + const unsigned channel = frame++ % 2; + scene.ui_quads[0].color = channel ? Color{0, 1, 0, 1} : Color{1, 0, 0, 1}; + renderer.render(scene); + const auto pixels = renderer.pixels(); + require(pixels.size() == std::size_t(renderer.width()) * renderer.height() * 4, + "Window capture dimensions must match the current target"); + require(pixels.at((8 * renderer.width() + 8) * 4 + channel) > 240, + "Window capture must contain the current frame, including while minimized"); + require(renderer.stats().validation_errors == 0, "Vulkan validation error"); + }; + auto await = [&](auto condition, int milliseconds) { + const auto deadline = Clock::now() + std::chrono::milliseconds(milliseconds); + do { + draw(); + if (condition()) + return true; + SDL_Delay(10); + } while (Clock::now() < deadline); + return false; + }; + for (const auto& [width, height] : {std::pair{480, 270}, std::pair{360, 300}}) { + renderer.resize(width, height); + require(await( + [&] { + int logical_width{}, logical_height{}, pixel_width{}, pixel_height{}; + SDL_GetWindowSize(window, &logical_width, &logical_height); + SDL_GetWindowSizeInPixels(window, &pixel_width, &pixel_height); + return logical_width == width && logical_height == height && + renderer.width() == static_cast(pixel_width) && + renderer.height() == static_cast(pixel_height); + }, + 2000), + "Window/target must converge to the requested resize"); + } + std::cout << "stage=resized width=" << renderer.width() << " height=" << renderer.height() + << " resize_events=" << events.resized << '\n' + << std::flush; + // Allow initial configure/focus events to settle before requesting a new state. + for (int i = 0; i < 25; ++i) { + draw(); + SDL_Delay(10); + } + require(SDL_MinimizeWindow(window), "Request minimizing the owned fixture window"); + if (!await([&] { return SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED; }, 2000)) { + std::cout << "SKIP: window system declined the minimize request\n"; + return 77; + } + const auto before = renderer.stats().frame; + for (int i = 0; i < 12; ++i) { + draw(); + SDL_Delay(10); + } + require(renderer.stats().frame == before + 12, "Minimized capture must keep progressing"); + std::cout << "stage=minimized fresh_frames=12 minimize_events=" << events.minimized + << " focus_lost_events=" << events.focus_lost << '\n' + << std::flush; + if (argc > 1) { + std::filesystem::create_directories(argv[1]); + renderer.capture(std::filesystem::path(argv[1]) / "minimized.ppm"); + } + const auto restored_before = events.restored; + require(SDL_RestoreWindow(window), "Request restoring the owned fixture window"); + bool restored = await( + [&] { + return !(SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) && + events.restored > restored_before; + }, + 1000); + bool activation_required{}; + if (!restored) { + activation_required = true; + require(SDL_RaiseWindow(window), "Request activation of the owned fixture window"); + restored = await( + [&] { + return !(SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) && + events.restored > restored_before; + }, + 2000); + } + if (!restored) { + std::cout << "SKIP: resize and 12 minimized captures passed; compositor did not " + "confirm restore/activation. Programmatic restoration is not supported " + "by every Wayland compositor.\n"; + return 77; + } + renderer.resize(400, 320); + require(await( + [&] { + int w{}, h{}, logical_width{}, logical_height{}; + SDL_GetWindowSize(window, &logical_width, &logical_height); + SDL_GetWindowSizeInPixels(window, &w, &h); + return logical_width == 400 && logical_height == 320 && + renderer.width() == static_cast(w) && + renderer.height() == static_cast(h); + }, + 2000), + "Restore followed by resize must rebuild the target"); + for (int i = 0; i < 12; ++i) + draw(); + if (argc > 1) + renderer.capture(std::filesystem::path(argv[1]) / "restored.ppm"); + std::cout << "stage=restored restored_events=" << events.restored + << " activation_required=" << activation_required + << " frames=" << renderer.stats().frame + << " validation_enabled=" << renderer.stats().validation_enabled + << " validation_errors=" << renderer.stats().validation_errors << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/runtime_player_tests.cpp b/tests/runtime_player_tests.cpp index c2c578d..ae467eb 100644 --- a/tests/runtime_player_tests.cpp +++ b/tests/runtime_player_tests.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include using Json = nlohmann::json; @@ -28,6 +29,90 @@ Json component(std::string type, Json fields) { Json entity(std::string id, Json parent, Json components) { return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}}; } +void physicsDebug(faset::player::SceneView& view) { + for (int dimension : {2, 3}) { + const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d"; + Json body{{"body_type", "dynamic"}, + {"half_extents", dimension == 2 ? Json{.75, .5} : Json{.75, .5, .25}}, + {"linear_velocity", dimension == 2 ? Json{2, 0} : Json{2, 0, 0}}}; + const Json authoredPose{{"position", {1, 2, 3}}, + {"rotation", {0, 0, std::numbers::pi_v / 2}}, + {"scale", {-2, 3, -4}}}; + Json scene{{"format", "faset.scene"}, + {"version", 1}, + {"id", "physics"}, + {"name", "physics"}, + {"dimension", dimension}, + {"instances", Json::array()}, + {"entities", + Json::array({entity("body", nullptr, + Json::array({component("faset.transform", authoredPose), + component("faset." + bodyName, body)}))})}}; + faset::runtime::RuntimeConfig config; + config.gravity = {0, 0, 0}; + faset::runtime::Runtime runtime(config); + runtime.load(scene); + runtime.advance(config.fixedDelta * 1.5); + const auto handle = runtime.find("body"); + const auto physical = runtime.transform(handle); + const auto displayed = runtime.presentation(handle); + check(physical.position[0] > displayed.position[0] + .01f, + "Fixture separates actual body pose from interpolated presentation"); + // This is the same explicit current-pose contract used by the Player; + // snapshotJson's presentation pose is intentionally unsuitable here. + Json physics{ + {"dimension", dimension}, + {"entities", Json::array({{{"parent", nullptr}, + {bodyName, runtime.fields(handle, "faset." + bodyName)}, + {"transform", + {{"position", physical.position}, + {"rotation", physical.rotation}, + {"scale", physical.scale}}}}})}}; + faset::render::Snapshot debug; + const auto originalCamera = debug.view_projection; + view.appendPhysicsDebug(debug, physics, .02f); + check(debug.draws.size() == (dimension == 2 ? 4 : 12), "Box outline edge count"); + check(debug.view_projection == originalCamera, "Debug overlay leaves camera unchanged"); + faset::render::Vec3 center{}; + for (const auto& edge : debug.draws) { + check(!edge.cast_shadow && edge.mesh, "Debug edges are unshadowed meshes"); + for (const auto& vertex : edge.mesh->vertices) + check(vertex.normal == faset::render::Vec3{0, 0, 0}, "Debug edge color is unlit"); + for (int axis = 0; axis < 3; ++axis) + center[axis] += edge.model[12 + axis] / float(debug.draws.size()); + } + for (int axis = 0; axis < 3; ++axis) + check(std::abs(center[axis] - physical.position[axis]) < .0001f, + "Outlines center on current Box2D/Box3D pose"); + // Rz(pi/2) turns the first X edge into world Y. Abs(scale) gives a + // three-unit X edge and 1.5-unit local Y half extent in both adapters. + const auto& first = debug.draws.front().model; + check(std::abs(first[0]) < .0001f && std::abs(first[1] - 3) < .0001f && + std::abs(first[12] - physical.position[0] - 1.5f) < .0001f, + "Negative nonuniform scale and box rotation match physics shape policy"); + if (dimension == 3) + check(std::abs(first[14] - physical.position[2] + 1) < .0001f, + "Box3D Z half extent includes absolute Z scale"); + auto invalid = physics; + invalid["entities"][0]["parent"] = "another-body"; + rejects([&] { view.appendPhysicsDebug(debug, invalid); }, + "Debug respects runtime root-only physical bodies"); + if (dimension == 2) { + invalid = physics; + invalid["entities"][0]["transform"]["rotation"][0] = .5; + rejects([&] { view.appendPhysicsDebug(debug, invalid); }, + "Box2D debug rejects rotation outside Z"); + } + rejects([&] { view.appendPhysicsDebug(debug, physics, 0); }, + "Debug thickness must be positive"); + auto opaque = scene; + opaque["entities"][0]["components"][1]["version"] = 2; + opaque["entities"][0]["components"][1]["fields"] = "future representation"; + faset::render::Snapshot skipped; + view.appendPhysicsDebug(skipped, opaque); + check(skipped.draws.empty(), "Debug does not interpret unknown body schema versions"); + } +} void run() { const auto folder = std::filesystem::temp_directory_path() / ("faset-player-test-" + faset::new_id()); @@ -74,6 +159,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); + physicsDebug(view); auto snapshot = view.build(scene, 16.f / 9.f); check(snapshot.draws.size() == 1, "SceneView builtin mesh"); check(snapshot.draws[0].model[12] == 3 && snapshot.draws[0].model[13] == 2 && @@ -95,6 +181,22 @@ void run() { view.build(bad, 1); check(!view.diagnostics().empty() && view.diagnostics()[0].starts_with("error:"), "missing asset is diagnostic, not silent success"); + auto opaque = scene; + opaque["entities"][1]["components"][1]["version"] = 2; + opaque["entities"][1]["components"][1]["fields"] = {{"asset", 42}, {"color", "future-format"}}; + const auto unchanged = opaque; + check(view.build(opaque, 1).draws.empty(), "Future mesh fields are opaque in editor preview"); + check(!view.diagnostics().empty() && + view.diagnostics()[0].find("unsupported faset.mesh") != std::string::npos, + "Opaque preview component has an actionable diagnostic"); + check(opaque == unchanged, "Preview preserves unknown component bytes"); + rejects([&] { world.load(opaque); }, "Player runtime rejects unknown component versions"); + opaque = scene; + opaque["entities"][1]["components"][0]["version"] = 2; + opaque["entities"][1]["components"][0]["fields"] = {{"position", "future-format"}}; + snapshot = view.build(opaque, 1); + check(snapshot.draws[0].model[12] == 1 && snapshot.draws[0].model[13] == 2, + "Future transform fields are not interpreted as v1 coordinates"); auto camera = faset::player::CameraSettings{}; camera.eye = camera.target; rejects([&] { view.build(scene, 1, camera); }, "reject degenerate camera"); diff --git a/tests/runtime_tutorials_projects.cpp b/tests/runtime_tutorials_projects.cpp new file mode 100644 index 0000000..a165598 --- /dev/null +++ b/tests/runtime_tutorials_projects.cpp @@ -0,0 +1,110 @@ +#include "Gameplay.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace faset::runtime; +void check(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +nlohmann::json read(const std::filesystem::path& path) { + nlohmann::json value; + std::ifstream input(path); + input >> value; + return value; +} +} // namespace +int main() { + try { + const std::filesystem::path project = FASET_EXAMPLE_PROJECT; + const auto manifest = read(project / "project.faset.json"); + const auto scene = read(project / manifest.at("start_scene").get()); + check(manifest.at("dimension") == FASET_EXAMPLE_DIMENSION, + "project dimension matches test"); + std::set ids; + for (const auto& entity : scene.at("entities")) { + check(ids.insert(entity.at("id").get()).second, "unique entity IDs"); + for (const auto& component : entity.at("components")) + check(ids.insert(component.at("id").get()).second, + "unique component IDs"); + } + const auto schemas = faset::gameplay::schema(); + check(schemas.size() == (FASET_EXAMPLE_DIMENSION == 3 ? 2u : 1u), + "3D module also exports independent Beacon component"); + Runtime world; + faset::gameplay::registerGameplay(world); + world.load(scene); + auto player = world.find("player"); + const auto start = world.transform(player); + for (int i = 0; i < 15; ++i) + world.singleStep(); + check(world.grounded(player), "player starts supported by actual physics"); + check(world.transform(world.find("win_marker")).position[1] < -40, + "victory hidden until completion"); + auto walk = [&](Vec3 target, bool allowJump, float tolerance = .15f) { + for (int tick = 0; tick < 650; ++tick) { + const auto p = world.transform(player).position; + const float dx = target[0] - p[0], + dz = FASET_EXAMPLE_DIMENSION == 3 ? target[2] - p[2] : 0; + if (std::hypot(dx, dz) < tolerance) { + for (int i = 0; i < 20; ++i) + world.singleStep(); + return; + } + InputState input; + input.horizontal = std::clamp(dx * 4.0f, -1.0f, 1.0f); + input.vertical = -std::clamp(dz * 4.0f, -1.0f, 1.0f); + input.jumpPressed = allowJump && world.grounded(player); + world.singleStep(input); + } + const auto p = world.transform(player).position; + throw std::runtime_error("Cannot reach waypoint " + std::to_string(target[0]) + "," + + std::to_string(target[2]) + " from " + std::to_string(p[0]) + + "," + std::to_string(p[1]) + "," + std::to_string(p[2])); + }; + // Drive the published level through input. No teleports are used to collect + // its tokens or bypass the gate; this exercises level reachability as well + // as the same C++ module linked into the actual Player. + const auto first = world.transform(world.find("token_1")).position; + walk(first, false); + check(world.transform(world.find("token_1")).position[1] > 1.5f, + "first token collected through movement"); + const auto middle = world.transform(world.find("token_2")).position; + walk(middle, true); + check(world.transform(world.find("token_2")).position[1] > 2, + "platform token collected with grounded jumps"); + const auto third = world.transform(world.find("token_3")).position; + walk(third, false); + check(world.transform(world.find("gate")).position[1] < -20, + "three pickups open physical gate"); + const auto goal = world.transform(world.find("goal")).position; + if (FASET_EXAMPLE_DIMENSION == 3) + walk({4, 0, goal[2]}, false); + walk(goal, false, .95f); + check(world.transform(world.find("win_marker")).position[1] > 2, + "exit completes objective and reveals victory marker"); + world.singleStep({0, 0, false, true}); + check(world.transform(world.find("gate")).position[1] > 0, "E resets gate"); + check(world.transform(world.find("win_marker")).position[1] < -40, "E clears victory"); + check(std::abs(world.transform(player).position[0] - start.position[0]) < .01f, + "E resets player spawn"); + check(world.transform(world.find("token_1")).position[1] < 1, "E restores pickups"); + const auto previousPlayer = player; + world.load(scene); + check(!world.valid(previousPlayer) && world.valid(world.find("player")), + "restart clears captured state and stale handles"); + check(world.diagnostics().empty(), "playthrough must not hide gameplay exceptions"); + std::cout << "Playable " << FASET_EXAMPLE_DIMENSION + << "D project reached its objective and reset\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/ui_tests.cpp b/tests/ui_tests.cpp index 3d4b2cf..30cfcca 100644 --- a/tests/ui_tests.cpp +++ b/tests/ui_tests.cpp @@ -229,6 +229,34 @@ int main() { declarative.layout(200, 100); click(declarative, *declarative.find("run")); check(action == 1 && declarative.find("run")->text == "Run", "layout reload lost callback"); + const auto initial_height = declarative.find("run")->layout.height; + const auto invalid_patch = + ui::Json{{"id", "root"}, + {"children", + ui::Json::array({{{"id", "run"}, {"layout", {{"height", 99}}}}, + {{"id", "bad-label"}, {"kind", "label"}, {"text", 123}}})}}; + rejected = false; + try { + declarative.apply_layout(invalid_patch); + } catch (...) { + rejected = true; + } + check(rejected && declarative.find("run")->layout.height == initial_height && + !declarative.find("bad-label"), + "Layout validation must reject all changes before mutation"); + rejected = false; + try { + declarative.apply_layout( + {{"id", "root"}, + {"children", + ui::Json::array({{{"id", "new-parent"}, + {"kind", "column"}, + {"children", ui::Json::array({{{"id", "run"}}})}}})}}); + } catch (...) { + rejected = true; + } + check(rejected && !declarative.find("new-parent"), + "Hot layout must not duplicate/reparent a retained widget ID"); std::cout << "UI: UTF-8, shaping, text/IME/clipboard, focus, transactions, " "layout, clipping, docking OK\n"; return 0; diff --git a/tools/blender_addon/README.md b/tools/blender_addon/README.md index afb72d6..7856818 100644 --- a/tools/blender_addon/README.md +++ b/tools/blender_addon/README.md @@ -8,4 +8,4 @@ Objects, mesh datablocks and materials receive `faset_id` custom properties. Ren The engine imports `manifest.json` or ordinary GLB/glTF. Gameplay components, physics settings and instance overrides are engine-owned data. The exporter does not write them. Without IDs, the engine does not promise reliable matching after renaming internal parts. Arbitrary procedural Blender materials require baking or an explicit engine material; this profile does not claim pixel-identical shading. -`bundle.py` is independent of Blender and has executable fixture tests. Blender UI/export execution still needs validation in an installed Blender version; this repository's tests do not substitute for that check. +`bundle.py` is independent of Blender and has executable fixture tests. The actual helper was also executed in unmodified Blender 4.5.3 LTS, followed by real Editor imports checking stable rename, geometry changes, deleted-output conflict and failure preservation. Reproduce with `python tools/verify_blender_roundtrip.py --blender PATH --editor PATH`. This background integration check does not substitute for testing every Blender UI/platform combination. See `docs/manual/editor/assets.md` for the user workflow and supported content profile. diff --git a/tools/compile_shader.py b/tools/compile_shader.py new file mode 100644 index 0000000..20eb7b8 --- /dev/null +++ b/tools/compile_shader.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Compile Slang and atomically publish SPIR-V with Faset reflection v1. + +The layout fingerprint contains the declared shader interface and actual SPIR-V +push-constant decorations, never compiler formatting or generated ID numbers. +A failed compiler invocation or reflection conversion leaves previous files intact. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import struct +import subprocess +import sys +import tempfile + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def canonical(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def value_type(value: dict) -> str: + kind = value["kind"] + if kind == "scalar": + return value["scalarType"] + if kind == "vector": + return f"{value_type(value['elementType'])}x{value['elementCount']}" + if kind == "matrix": + return f"{value_type(value['elementType'])}x{value['rowCount']}x{value['columnCount']}" + if kind == "array": + return f"{value_type(value['elementType'])}[{value['elementCount']}]" + raise ValueError(f"Unsupported reflected value type: {kind}") + + +def interface(fields: list[dict], category: str) -> tuple[list[dict], list[dict]]: + locations, builtins = [], [] + def visit(field: dict, offset: int = 0) -> None: + ty, binding = field["type"], field.get("binding", {}) + if ty["kind"] == "struct": + for child in ty["fields"]: + visit(child, offset + binding.get("index", 0)) + elif binding.get("kind") == category: + locations.append({"location": offset + binding["index"], "type": value_type(ty)}) + elif field.get("semanticName", "").startswith("SV_"): + builtins.append({"semantic": field["semanticName"], "type": value_type(ty)}) + else: + raise ValueError("Shader interface field lacks a supported location or builtin") + for field in fields: + visit(field) + return sorted(locations, key=lambda item: item["location"]), sorted(builtins, key=lambda item: item["semantic"]) + + +def spirv_push_layout(data: bytes) -> list[dict]: + if len(data) < 20 or len(data) % 4: + raise ValueError("Malformed SPIR-V byte length") + words = struct.unpack(f"<{len(data) // 4}I", data) + if words[0] != 0x07230203 or words[1] > 0x00010600 or not words[3] or words[4]: + raise ValueError("Unsupported SPIR-V header") + pointers, variables, decorations = {}, [], {} + position = 5 + while position < len(words): + count, opcode = words[position] >> 16, words[position] & 0xFFFF + if not count or position + count > len(words): + raise ValueError("Malformed SPIR-V instruction") + operands = words[position + 1:position + count] + if opcode == 32 and len(operands) == 3: # OpTypePointer + pointers[operands[0]] = (operands[1], operands[2]) + elif opcode == 59 and len(operands) >= 3 and operands[2] == 9: # PushConstant OpVariable + variables.append(operands[0]) + elif opcode == 72 and len(operands) >= 3: # OpMemberDecorate + member = decorations.setdefault(operands[0], {}).setdefault(operands[1], {}) + if operands[2] in (4, 5): + member["matrix_layout"] = "row-major" if operands[2] == 4 else "column-major" + elif operands[2] in (7, 35): + if len(operands) != 4: + raise ValueError("Malformed SPIR-V member decoration") + member["matrix_stride" if operands[2] == 7 else "offset"] = operands[3] + position += count + result = [] + for pointer in variables: + storage, structure = pointers[pointer] + if storage != 9: + raise ValueError("Invalid SPIR-V push-constant pointer") + members = [{"member": index, **layout} for index, layout in sorted(decorations.get(structure, {}).items())] + result.append({"members": members}) + return result + + +def normalize(raw: dict, bytecode: bytes, entry_name: str) -> dict: + entry = next(item for item in raw["entryPoints"] if item["name"] == entry_name) + used = {item["name"]: bool(item["binding"].get("used", True)) for item in entry.get("bindings", [])} + descriptors, constants = [], [] + for parameter in raw.get("parameters", []): + binding, ty = parameter["binding"], parameter["type"] + if binding["kind"] == "pushConstantBuffer": + block = ty["elementType"] + size = next(item["value"] for item in block["sizes"] if item["kind"] == "uniform") + members = [{"name": member["name"], "offset": member["binding"]["offset"], "size": member["binding"]["size"], "type": value_type(member["type"])} for member in block["fields"]] + constants.append({"name": parameter["name"], "offset": 0, "size": size, "members": sorted(members, key=lambda item: item["offset"])}) + elif binding["kind"] == "descriptorTableSlot": + count = binding.get("count", 1) + if ty["kind"] == "array": + count = ty["elementCount"] + ty = ty["elementType"] + if ty["kind"] == "samplerState": + descriptor_type = "sampler" + elif ty["kind"] == "resource" and ty.get("baseShape") == "texture2D": + descriptor_type = "sampled_image_2d" + else: + raise ValueError(f"Unsupported descriptor kind: {ty}") + descriptors.append({"name": parameter["name"], "set": binding.get("space", 0), "binding": binding["index"], "type": descriptor_type, "count": count, "used": used.get(parameter["name"], True)}) + else: + raise ValueError(f"Unsupported global shader binding: {binding['kind']}") + inputs, input_builtins = interface(entry.get("parameters", []), "varyingInput") + outputs, output_builtins = interface([entry["result"]] if "result" in entry else [], "varyingOutput") + layout = {"stage": entry["stage"], "descriptors": sorted(descriptors, key=lambda item: (item["set"], item["binding"])), "push_constants": constants, "inputs": inputs, "outputs": outputs, "input_builtins": input_builtins, "output_builtins": output_builtins, "spirv_push_constants": spirv_push_layout(bytecode)} + return {"format": "faset.shader-reflection", "version": 1, "source_entry": entry_name, "entry_point": "main", "matrix_convention": "column-major host matrices; Slang SPIR-V decorations recorded explicitly", "spirv_sha256": digest(bytecode), "layout_fingerprint": digest(canonical(layout)), "layout": layout} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--compiler", required=True) + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--entry", required=True) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".shader-", dir=args.output) as temporary: + directory = Path(temporary) + spirv = directory / f"{args.entry}.spv" + raw = directory / f"{args.entry}.slang-reflection.json" + process = subprocess.run([args.compiler, str(args.source), "-entry", args.entry, "-target", "spirv", "-profile", "spirv_1_6", "-matrix-layout-column-major", "-o", str(spirv), "-reflection-json", str(raw)]) + if process.returncode: + return process.returncode + normalized = normalize(json.loads(raw.read_text(encoding="utf-8")), spirv.read_bytes(), args.entry) + manifest = directory / f"{args.entry}.reflection.json" + manifest.write_text(json.dumps(normalized, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + # Reflection is the commit record. A reader racing these replacements rejects + # a hash mismatch and keeps its existing pipelines until the complete pair arrives. + for artifact in (raw, spirv, manifest): + os.replace(artifact, args.output / artifact.name) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, KeyError, StopIteration) as error: + print(f"Shader reflection error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/measure_workflows.py b/tools/measure_workflows.py new file mode 100644 index 0000000..ee9c7f2 --- /dev/null +++ b/tools/measure_workflows.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Measure real Editor/import/C++ iteration on a disposable copy of a sample project. + +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 datetime +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import time + + +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) + args = parser.parse_args() + editor, source, output = args.editor.resolve(), args.project.resolve(), args.output.resolve() + if output.exists(): + raise RuntimeError("Use a new output directory; existing evidence is never overwritten") + output.mkdir(parents=True) + project = output / "project" + shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1")) + samples = [] + + 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(): + wrapped = ["/usr/bin/time", "-f", '{"peak_rss_kib":%M}', "-o", str(memory), *command] + started = time.perf_counter() + result = subprocess.run(wrapped, cwd=output, capture_output=True, text=True, + encoding="utf-8", timeout=timeout) + elapsed = time.perf_counter() - started + (output / (label + "-stdout.txt")).write_text(result.stdout, encoding="utf-8") + (output / (label + "-stderr.txt")).write_text(result.stderr, encoding="utf-8") + sample = {"name": label, "seconds": elapsed, "returncode": result.returncode, + "peak_rss_kib": json.loads(memory.read_text())["peak_rss_kib"] if memory.exists() else None} + samples.append(sample) + (output / "progress.json").write_text(json.dumps(samples, indent=2), encoding="utf-8") + if result.returncode: + raise RuntimeError(f"{label} failed: {result.stderr[-4000:]} {result.stdout[-2000:]}") + print(f"{label}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True) + return result + + 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}") + return document + + for index in range(3): + call(f"headless-startup-{index + 1}", "faset_project") + # Separate GUI startup from headless command latency. Includes two frames and + # shutdown, not a claimed first-visible-frame timestamp. + run("editor-gui-two-frames", [editor, "--project", project, "--frames", "2"]) + manifest = project / "Assets/exit-arch/manifest.json" + if manifest.exists(): + call("import-empty-cache", "faset_import", {"path": "Assets/exit-arch/manifest.json"}) + call("import-existing-generation", "faset_import", {"path": "Assets/exit-arch/manifest.json"}) + call("initial-debug-build", "faset_build") + call("unchanged-debug-build", "faset_build") + gameplay = project / "Scripts/Gameplay.cpp" + original = gameplay.read_bytes() + gameplay.write_bytes(original + b"\n// Measurement: one gameplay translation unit changed.\n") + # Verify the user-facing stale status before a successful replacement. + assert call("schema-stale-after-edit", "faset_schema_status")["stale"] + iteration_started = time.perf_counter() + built = call("changed-debug-build", "faset_build")["result"] + profile = output / "development-player.json" + 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", + "--headless", "--frames", "1", "--profile", profile]) + iteration_seconds = time.perf_counter() - iteration_started + assert not call("schema-current-after-build", "faset_schema_status")["stale"] + # Keep original inputs in this disposable project, without implying its last + # built binary matches the restored source signature. + gameplay.write_bytes(original) + engine = Path(__file__).resolve().parents[1] + revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=engine, + capture_output=True, text=True).stdout.strip() + dirty = subprocess.run(["git", "status", "--porcelain"], cwd=engine, + capture_output=True, text=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) + report = { + "format": "faset.workflow-measurements", "version": 1, + "recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "revision": revision, "working_tree_dirty": bool(dirty), + "host": {"platform": platform.platform(), "cpu": cpu, + "logical_cpus": os.cpu_count(), "python": platform.python_version()}, + "source_project": str(source), "editor": str(editor), + "build_configuration": "Debug", "samples": samples, + "changed_build_and_one_frame_process_seconds": iteration_seconds, + "player": json.loads(profile.read_text()), + "method": [ + "Fresh disposable project, engine dependency archives and OS file caches already available.", + "Each Editor command starts a new process and includes shutdown in wall time.", + "GUI startup sample includes window creation, two frames, and shutdown.", + "Changed build appends a harmless comment, recompiles Gameplay.cpp and relinks native outputs.", + "Combined iteration runs the returned Debug Player in a separate process for one offscreen frame.", + "Peak RSS is GNU time maximum over each command and waited-for children, not a sum.", + "Numbers include ambient host load; there is no warm-up exclusion or real-time frame guarantee." + ]} + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/verify_offline_build.py b/tools/verify_offline_build.py new file mode 100644 index 0000000..248ca33 --- /dev/null +++ b/tools/verify_offline_build.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Linux clean-checkout acceptance in a network namespace with no external network. + +Uses committed HEAD, prefetched checksum-verified archives and local system tools. +Does not download dependencies, install packages or change the source checkout. +""" +import argparse +import datetime +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--parallel", type=int, default=4) + args = parser.parse_args() + if sys.platform != "linux" or not shutil.which("unshare"): + parser.error("This check requires Linux unshare with user/network namespace support") + if not 1 <= args.parallel <= 64: + parser.error("--parallel must be between 1 and 64") + root, output = Path(__file__).resolve().parents[1], args.output.resolve() + if output.exists(): + parser.error("Use a new output directory to preserve previous evidence") + subprocess.run([sys.executable, root / "tools/fetch_dependencies.py", "--verify-only"], check=True) + if not (root / ".cache/slang/bin/slangc").is_file(): + parser.error("Prefetch Slang before starting the offline check") + # Verify capability before making a potentially expensive copy. + subprocess.run(["unshare", "--user", "--map-root-user", "--net", "true"], check=True) + output.mkdir(parents=True) + revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + archive, checkout = output / "source.tar", output / "checkout" + with archive.open("wb") as stream: + subprocess.run(["git", "archive", "--format=tar", revision], cwd=root, stdout=stream, check=True) + with tarfile.open(archive) as content: + content.extractall(checkout, filter="data") + archive.unlink() + shutil.copytree(root / ".cache/downloads", checkout / ".cache/downloads") + shutil.copytree(root / ".cache/slang", checkout / ".cache/slang", symlinks=True) + script = output / "offline_driver.py" + script.write_text('''import json, pathlib, socket, subprocess, sys +root = pathlib.Path(sys.argv[1]) +# No external interfaces exist in this newly created network namespace. +with socket.socket() as connection: + connection.settimeout(2) + try: + connection.connect(("1.1.1.1", 443)) + except OSError as error: + print("External network unavailable:", error, flush=True) + else: + raise RuntimeError("Offline check unexpectedly has external connectivity") +subprocess.run([sys.executable, "tools/fetch_dependencies.py", "--verify-only"], cwd=root, check=True) +subprocess.run(["cmake", "--preset", "linux-debug"], cwd=root, check=True) +subprocess.run(["cmake", "--build", "--preset", "linux-debug", "--parallel", sys.argv[2]], cwd=root, check=True) +subprocess.run(["ctest", "--preset", "linux-debug", "-LE", "gpu", "--timeout", "120"], cwd=root, check=True) +''', encoding="utf-8") + started = time.perf_counter() + with (output / "build.log").open("w", encoding="utf-8") as log: + result = subprocess.run(["unshare", "--user", "--map-root-user", "--net", + sys.executable, script, checkout, str(args.parallel)], stdout=log, stderr=subprocess.STDOUT) + report = {"format": "faset.offline-build", "version": 1, "revision": revision, + "recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "seconds": time.perf_counter() - started, "returncode": result.returncode, + "network": "fresh user/network namespace; external connection must fail", + "source": "git archive of committed HEAD; no existing build directory", + "inputs": "prefetched archives + Slang; system compiler, SDK and development libraries", + "tests": "full native build, CPU CTests; GPU/window execution verified separately"} + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report, indent=2)) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_playable_exports.py b/tools/verify_playable_exports.py new file mode 100644 index 0000000..9a35e6d --- /dev/null +++ b/tools/verify_playable_exports.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Export the checked-in playable projects through the real Editor and run relocated games. + +Requires a built Editor, native C++ build tools, Slang and a Vulkan 1.3 driver. +Uses --headless for offscreen rendering. Windows CI can use the pinned SwiftShader +setup; the report records whether Khronos validation was actually available. +Output must be new or empty. Relocated games are retained in an external temporary +directory, and captures/profiles/logs are copied into the evidence directory. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, value: object) -> None: + path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +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) + started = time.monotonic() + process = subprocess.Popen(command, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", + start_new_session=os.name != "nt", + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0) + timed_out = False + try: + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + else: + os.killpg(process.pid, signal.SIGKILL) + stdout, stderr = process.communicate() + record = {"arguments": command, "cwd": str(cwd), "seconds": time.monotonic() - started, + "exit_code": process.returncode, "timed_out": timed_out, + "stdout": stdout, "stderr": stderr} + write_json(log, record) + require(not timed_out and process.returncode == 0, + f"Command failed; inspect {log}: {stderr[-2000:]} {stdout[-2000:]}") + return record + + +def command(editor: Path, engine: Path, project: Path, name: str, arguments: dict, log: Path) -> dict: + response = run([editor, "--engine", engine, "--project", project, "--command", + json.dumps({"name": name, "arguments": arguments}), "--wait"], engine, log) + result = json.loads(response["stdout"]) + require(result.get("state") == "succeeded", f"Editor job did not succeed: {log}") + return result + + +def relative_path(root: Path, relative: str) -> Path: + path = (root / relative).resolve() + require(path.is_relative_to(root.resolve()), f"Package path escapes its root: {relative}") + return path + + +def verify_package(directory: Path) -> dict: + manifest = read_json(directory / "manifest.json") + require(manifest.get("format") == "faset.export" and manifest.get("version") == 1, + "Unsupported export manifest") + require(manifest["configuration"] == "Release", "Playable exports must use Release") + for entry in manifest["files"]: + path = relative_path(directory, entry["path"]) + 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}") + return manifest + + +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()) + require(width > 0 and height > 0 and len(pixels) == width * height * 3, + "Incomplete screenshot") + step = 3 * max(1, width * height // 10000) + colors = len({pixels[index:index + 3] for index in range(0, len(pixels), step)}) + require(colors >= 6, "Screenshot lacks the expected game geometry/colors") + return {"width": width, "height": height, "sampled_colors": colors, "sha256": sha256(path)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--engine", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--editor", type=Path, required=True) + 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") + 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}") + require(not output.exists() or not any(output.iterdir()), "Output directory must be new/empty") + output.mkdir(parents=True, exist_ok=True) + standalone = (args.standalone_root.resolve() if args.standalone_root else + Path(tempfile.mkdtemp(prefix="faset-playable-exports-"))) + require(not standalone.is_relative_to(engine) and not standalone.is_relative_to(output), + "Standalone games must be outside the engine and evidence trees") + require(not standalone.exists() or not any(standalone.iterdir()), + "Standalone root must be new/empty") + standalone.mkdir(parents=True, exist_ok=True) + evidence, projects = output / "evidence", output / "projects" + evidence.mkdir() + projects.mkdir() + report = {"format": "faset.playable-export-verification", "version": 1, + "started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform, + "engine": str(engine), "editor": str(editor), "standalone_root": str(standalone), + "frames_per_game": 120, "status": "running", "projects": []} + disabled = output / "projects-offline" + try: + 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, "source_inputs": inputs} + report["projects"].append(item) + if dimension == 3: + imported = command(editor, engine, project, "faset_import", + {"path": "Assets/exit-arch/manifest.json"}, evidence / f"{name}-import.json") + item["import"] = imported["result"] + exported = command(editor, engine, project, "faset_export", + {"document": scene["id"], "output": "Exports"}, + evidence / f"{name}-export.json") + pointer = read_json(project / "Exports/current.json") + require(pointer["format"] == "faset.export-pointer" and pointer["version"] == 1, + "Invalid current export pointer") + generation = relative_path(project / "Exports", pointer["directory"]) + require(generation == Path(exported["result"]["directory"]).resolve(), + "Export response and published pointer disagree") + manifest = verify_package(generation) + require(dimension != 3 or bool(manifest["asset_generations"]), + "3D game did not package its imported Blender asset") + relocated = standalone / name + shutil.copytree(generation, relocated) + verify_package(relocated) + shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json") + item.update({"generation": pointer["generation"], "configuration": "Release", + "standalone_directory": str(relocated), "executable": manifest["executable"], + "package_file_count": len(manifest["files"]), + "asset_generations": manifest["asset_generations"]}) + write_json(output / "report.json", report) + # Hide exactly our disposable source-project paths while launching both games. + # An empty, unrelated cwd also catches assumptions about the current directory. + projects.rename(disabled) + working = standalone / "empty-working-directory" + working.mkdir() + for item in report["projects"]: + require(not projects.exists(), "Source project paths must be unavailable during launch") + name, dimension = item["name"], item["dimension"] + relocated = Path(item["standalone_directory"]) + player = relative_path(relocated, item["executable"]) + validation = run([player, "--validate"], working, evidence / f"{name}-validate.json", 120) + require(json.loads(validation["stdout"]).get("validated") is True, + "Standalone CPU validation failed") + 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) + 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") + measured = read_json(profile) + require(measured["format"] == "faset.player-profile" and measured["version"] == 1 and + measured["completed_frames"] == 120 and measured["dimension"] == dimension and + measured["presentation_mode"] == "offscreen" and measured["validation_errors"] == 0 and + len(measured["samples"]) == 120, "Invalid or incomplete frame profile") + require(all(frame["gpu_allocated_bytes"] > 0 for frame in measured["samples"]), + "Frame profile lacks Vulkan allocation measurements") + item.update({"device": measured["device"], "validation_enabled": measured["validation_enabled"], + "validation_errors": 0, "completed_frames": 120, + "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") + write_json(output / "report.json", report) + report["status"] = "passed" + except Exception as error: + report.update({"status": "failed", "error": str(error)}) + raise + finally: + if disabled.exists() and not projects.exists(): + disabled.rename(projects) + report["finished_utc"] = datetime.now(timezone.utc).isoformat() + write_json(output / "report.json", report) + print(f"Both relocated Release games passed: {output / 'report.json'}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())