Checkpoint 3: deliver playable samples and complete native authoring workflows

This commit is contained in:
Emil
2026-09-18 04:29:41 +03:00
parent fad5eb4e55
commit d834cfad67
92 changed files with 8335 additions and 353 deletions
+3
View File
@@ -5,3 +5,6 @@ licenses/*.txt -whitespace
*.ttf binary
assets/fonts/OFL.txt -whitespace
*.blend binary
*.glb binary
+16 -3
View File
@@ -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/*
+5
View File
@@ -19,3 +19,8 @@ CMakeUserPresets.json
.env
.env.*
!.env.example
# Per-project authoring journals, caches, native builds and exports
**/.faset/
**/Exports/
*.blend1
+11 -1
View File
@@ -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 $<TARGET_FILE:faset_editor>)
set_tests_properties(editor_gui_mcp PROPERTIES LABELS "gpu;window" TIMEOUT 180)
endif()
endif()
endif()
+8 -1
View File
@@ -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
+5
View File
@@ -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<const char*>(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));
}
+90 -57
View File
@@ -7,6 +7,7 @@
#include <thread>
#ifdef FASET_HAS_EDITOR_UI
#include <faset/editor/editor_ui.hpp>
#include <faset/editor/project_launcher.hpp>
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;
+188 -22
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp>
@@ -9,12 +10,103 @@
#include <iostream>
#include <set>
#include <stdexcept>
#include <vector>
#if defined(_WIN32)
#define NOMINMAX
#include <windows.h>
#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<double, std::milli>(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<double> values) {
if (values.empty())
return nullptr;
std::sort(values.begin(), values.end());
auto percentile = [&](double fraction) {
return values[static_cast<std::size_t>(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<ProfileSample>& samples) {
Json frames = Json::array();
std::vector<double> 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<std::string>());
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<unsigned char>(bytes[0]) != 0x03 ||
static_cast<unsigned char>(bytes[1]) != 0x02 ||
static_cast<unsigned char>(bytes[2]) != 0x23 ||
static_cast<unsigned char>(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<std::string> 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/<id>/.\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<ProfileSample> profile;
if (!profilePath.empty())
profile.reserve(static_cast<std::size_t>(maximumFrames));
Json firstFrameMs = nullptr;
std::set<std::string> 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<double>(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<float>(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<float>(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)},
+435
View File
@@ -0,0 +1,435 @@
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <faset/core/io.hpp>
#include <faset/editor/project_launcher.hpp>
#include <fstream>
#include <set>
#include <thread>
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<const char*>(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<Json::array_t&>(), [&](const Json& record) {
return !record.is_string() || record.get<std::string>() == 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<void()> 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<fs::path> pending_browse;
std::optional<ProjectSelection> 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<fs::path> paths;
for (const auto& record : recent_records(recents_file)) {
if (!record.is_string())
continue;
try {
const auto selection = open_project(normalize(record.get<std::string>()));
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<fs::path> 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<render::Event>& 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<Impl>(r, engine, initial, recents)) {}
ProjectLauncher::~ProjectLauncher() = default;
void ProjectLauncher::frame(const std::vector<render::Event>& events) {
impl_->frame(events);
}
const render::Snapshot& ProjectLauncher::snapshot() const {
return impl_->frame_data;
}
ui::Context& ProjectLauncher::widgets() {
return impl_->ui;
}
const std::optional<ProjectSelection>& ProjectLauncher::selection() const {
return impl_->selected;
}
bool ProjectLauncher::cancelled() const {
return impl_->cancelled;
}
std::optional<ProjectSelection> 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
+40
View File
@@ -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"}
]}
]
}]
}
}
+4
View File
@@ -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)
+27
View File
@@ -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()
+20 -10
View File
@@ -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)
+15
View File
@@ -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()
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+56
View File
@@ -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.
+71
View File
@@ -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.
+120
View File
@@ -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/<sha256>.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
`<source>.faset-import.json` sidecars. Sidecars preserve AssetId and import settings.
Also commit `<source>.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.
+99
View File
@@ -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/<generation>`. `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.
+76
View File
@@ -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.
+128
View File
@@ -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.
+174
View File
@@ -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 &lt;scene name&gt;** 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.
+6
View File
@@ -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.
+2 -1
View File
@@ -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
+21 -1
View File
@@ -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.
+8
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
.faset/
Exports/
+55
View File
@@ -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.
@@ -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": []
}
@@ -0,0 +1,157 @@
#include "Gameplay.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <iostream>
#include <map>
#include <stdexcept>
#include <tuple>
namespace faset::gameplay {
namespace {
constexpr bool is3D = false;
constexpr const char* collectorType = "example.collector_2d";
using Key = std::tuple<std::uint64_t, std::uint32_t, std::uint64_t>;
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<runtime::EntityHandle, 3> tokens;
std::array<runtime::Transform, 3> tokenPoses;
std::array<bool, 3> 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<std::map<Key, Round>>();
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<std::string>());
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
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
@@ -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"
}
+2
View File
@@ -0,0 +1,2 @@
.faset/
Exports/
@@ -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')
@@ -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"
}
}
@@ -0,0 +1,5 @@
{
"asset_id": "5832763b-3ed0-44d6-9088-0b524f196a91",
"schema_version": 1,
"settings": {}
}
+80
View File
@@ -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.
@@ -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": []
}
@@ -0,0 +1,28 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
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<float>(dt);
world.setTransform(self, pose);
};
runtime.registerBehavior("example.beacon", std::move(behavior));
}
} // namespace beacon
@@ -0,0 +1,157 @@
#include "Gameplay.hpp"
#include "Extensions/Beacon.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <iostream>
#include <map>
#include <stdexcept>
#include <tuple>
namespace faset::gameplay {
namespace {
constexpr bool is3D = true;
constexpr const char* collectorType = "example.collector_3d";
using Key = std::tuple<std::uint64_t, std::uint32_t, std::uint64_t>;
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<runtime::EntityHandle, 3> tokens;
std::array<runtime::Transform, 3> tokenPoses;
std::array<bool, 3> 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<std::map<Key, Round>>();
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<std::string>());
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
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
@@ -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"
}
+1
View File
@@ -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);
+2
View File
@@ -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;
+2
View File
@@ -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:
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <faset/ui/ui.hpp>
#include <optional>
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<render::Event>&);
const render::Snapshot& snapshot() const;
ui::Context& widgets();
const std::optional<ProjectSelection>& selection() const;
bool cancelled() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
std::optional<ProjectSelection>
run_project_launcher(const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {},
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {});
} // namespace faset::editor
+6
View File
@@ -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<PluginManager> plugins_;
std::map<std::string, std::string> submitted_sources_;
std::string schema_source_signature_;
bool schema_loaded_ = false;
std::string schema_error_;
};
} // namespace faset::editor
+5
View File
@@ -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<std::string>& diagnostics() const;
+10 -1
View File
@@ -79,11 +79,16 @@ struct Snapshot {
std::vector<Quad> ui_quads;
std::vector<Text> 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 {
+2
View File
@@ -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&);
+6 -1
View File
@@ -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
+4
View File
@@ -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");
+25 -5
View File
@@ -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<const stbi_uc*>(source_bytes.data());
const auto length = static_cast<int>(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)
+140 -1
View File
@@ -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<double>() > 0 &&
settings["fixed_delta"].get<double>() <= 1,
"simulation.fixed_delta",
"Fixed delta must be greater than zero and at most one second");
for (const auto& [name, maximum] :
std::map<std::string, int>{{"max_catch_up_ticks", 1024}, {"physics_substeps", 128}})
if (settings.contains(name)) {
const auto& value = settings[name];
require(value.is_number_integer() && value.get<double>() >= 1 &&
value.get<double>() <= maximum,
"simulation.integer", "Invalid simulation setting: " + name);
}
if (settings.contains("gravity"))
validate_field(settings["gravity"], {{"type", "vec3"}});
}
std::set<std::string> ids;
std::map<std::string, std::string> 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<const std::string&>().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<const std::string&>().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<std::string> local_ids;
for (const auto& item : additions["entities"])
if (item.contains("id") && item["id"].is_string())
local_ids.insert(item["id"].get<std::string>());
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<std::string>()))
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<std::string>())["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<std::string>();
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<std::string>();
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<std::string>();
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"
+56 -14
View File
@@ -32,14 +32,30 @@ struct Resolver {
return &item;
return nullptr;
}
void remap_references(Json& entity, const std::map<std::string, std::string>& mapping) {
for (auto& component : entity["components"]) {
const auto type = component.at("type").get<std::string>();
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<std::string>()))
value = mapping.at(value.get<std::string>());
}
}
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<std::string, std::string> ids;
std::map<std::string, std::string> ids, entity_ids;
for (const auto& item : scene["entities"]) {
const auto id = item.at("id").get<std::string>();
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<std::string>();
ids[cid] = path.empty() ? cid : scoped_id(root, path, cid);
@@ -56,16 +72,8 @@ struct Resolver {
const auto source_id = component.at("id").get<std::string>();
component["id"] = ids.at(source_id);
component["source_id"] = source_id;
const auto type = component.at("type").get<std::string>();
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<std::string>()))
value = ids.at(value.get<std::string>());
}
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<std::string, std::string> local_ids;
for (const auto& entity : expanded)
if (entity.at("origin").at("path") == nested_path)
local_ids[entity.at("origin").at("object").get<std::string>()] =
entity.at("id").get<std::string>();
for (const auto& addition : instance.value("additions", Json::array())) {
const auto id = addition.at("id").get<std::string>();
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<std::string>();
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<std::string>());
if (item.contains("parent") && !item["parent"].is_null()) {
const auto parent = item["parent"].get<std::string>();
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<std::string>();
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()}});
+6 -2
View File
@@ -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<std::string>());
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)) {
+36 -3
View File
@@ -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.",
+1029 -105
View File
File diff suppressed because it is too large Load Diff
+49 -4
View File
@@ -1,11 +1,13 @@
#include <algorithm>
#include <faset/editor/mcp.hpp>
#include <iostream>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <cerrno>
#include <csignal>
#include <poll.h>
#include <pthread.h>
#include <unistd.h>
#endif
@@ -126,7 +128,6 @@ std::vector<std::string> 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<std::string> 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<DWORD>(std::min<std::size_t>(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<std::size_t>(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
+23 -7
View File
@@ -14,11 +14,25 @@
namespace faset::editor {
namespace {
void append(void* context, const char* bytes, std::uint64_t size) {
auto& output = *static_cast<std::string*>(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<std::size_t>(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<ResponseBuffer*>(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<std::size_t>(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")),
+171 -52
View File
@@ -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<std::string>().empty(),
"project.name", "Project name must be a nonempty string");
const auto dimension = value.value("dimension", 3);
require(dimension == 2 || dimension == 3, "project.dimension",
"Project dimension must be 2 or 3");
if (value.contains("start_scene")) {
require(value.at("start_scene").is_string(), "project.start_scene",
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(config_.project_root, 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<std::filesystem::path> 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<std::string>());
// 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<std::string>();
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<std::string>().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<std::string>().empty(),
"project.start_scene", "Choose a saved scene inside the project");
const auto file = project_path(config_.project_root, field.get<std::string>());
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<std::string>());
request.settings = args.value("settings", Json(nullptr));
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
auto task = std::make_shared<ImportTask>();
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<std::string>());
request.settings = args.value("settings", Json(nullptr));
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
auto task = std::make_shared<ImportTask>();
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<std::string>()))}};
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<std::string>()));
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(
+98 -6
View File
@@ -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 <std::size_t N>
@@ -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<std::string, const Json*> byId;
for (const auto& entity : entities)
if (!byId.emplace(entity.at("id").get<std::string>(), &entity).second)
for (const auto& entity : entities) {
const auto id = entity.at("id").get<std::string>();
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<std::string>();
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<int>()) +
" on entity " + id + "; opaque data is preserved");
}
}
std::unordered_map<std::string, render::Mat4> matrices;
std::set<std::string> 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::Mesh>(*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
+69 -39
View File
@@ -1,3 +1,4 @@
#include "shader_contract.hpp"
#include <SDL3/SDL.h>
#include <SDL3/SDL_vulkan.h>
#include <algorithm>
@@ -41,13 +42,14 @@ std::array<float, 4> point(const Mat4& m, std::array<float, 4> 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<std::string, 3> 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<std::filesystem::path> 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<std::uint32_t> bytes(static_cast<std::size_t>(size) / 4);
file.seekg(0);
file.read(reinterpret_cast<char*>(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<std::size_t>(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<std::uint32_t> 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<double, std::milli>(
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<std::uint32_t>(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<double, std::milli>(std::chrono::steady_clock::now() - start)
+140
View File
@@ -0,0 +1,140 @@
#include "shader_contract.hpp"
#include <cstring>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/render/renderer.hpp>
#include <stdexcept>
#include <string_view>
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<const char*> 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<bool>(),
"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<std::uint32_t>& 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<const char*>(&words[offset + 3]);
const auto available = (count - 3) * sizeof(std::uint32_t);
const auto* terminator = static_cast<const char*>(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::ShaderCode, 3>
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
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <array>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace faset::render::detail {
struct ShaderCode {
std::vector<std::uint32_t> words;
std::string layout_fingerprint;
};
std::array<ShaderCode, 3> load_shader_bundle(const std::filesystem::path& directory);
} // namespace faset::render::detail
+48 -14
View File
@@ -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<unsigned char>(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<std::string> ids;
std::function<void(const Json&)> validate = [&](const Json& j) {
std::function<void(const Json&, const std::string&)> validate = [&](const Json& j,
const std::string& parent) {
const auto id = j.at("id").get<std::string>();
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<std::string>())))
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<float>();
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<void(Widget&, const Json&)> 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<std::string>();
@@ -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) {
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <vector>
// Deterministic two-pixel test images generated locally; no external media.
namespace faset::test_images {
inline const std::vector<unsigned char> 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<unsigned char> 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<unsigned char> 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
+112 -1
View File
@@ -1,3 +1,4 @@
#include "assets_image_fixtures.hpp"
#include <bit>
#include <chrono>
#include <faset/assets/asset_pipeline.hpp>
@@ -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<std::string>()
.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';
+164
View File
@@ -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");
+110
View File
@@ -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")
+75
View File
@@ -0,0 +1,75 @@
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
#include <iostream>
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<std::tuple<std::string, Json, std::string>>{
{"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;
}
}
+167
View File
@@ -0,0 +1,167 @@
#include <cmath>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
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<std::uint64_t>();
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<std::uint64_t>();
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;
}
}
+138
View File
@@ -0,0 +1,138 @@
#include <faset/core/io.hpp>
#include <faset/editor/project_launcher.hpp>
#include <iostream>
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<render::Event> 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;
}
}
+135
View File
@@ -0,0 +1,135 @@
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
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;
}
}
+127
View File
@@ -0,0 +1,127 @@
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
#include <thread>
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<std::uint64_t>() + 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;
}
}
+227
View File
@@ -0,0 +1,227 @@
#include <cmath>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
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<std::string>() + "-position-0";
}
float position(const Json& object) {
return object["components"][0]["fields"]["position"][0].get<float>();
}
} // 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<std::string>();
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<std::string>() + "-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<std::uint64_t>();
text(ui, "simulation-tick-rate", "120");
check(std::abs(query(session, ui)["scene"]["simulation"]["fixed_delta"].get<double>() -
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<std::string>());
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<std::string>());
const auto opaque_component = opaque["components"][0]["id"].get<std::string>();
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;
}
}
+72
View File
@@ -1,7 +1,10 @@
#include "assets_image_fixtures.hpp"
#include <cmath>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <fstream>
#include <iostream>
#include <thread>
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<const char*>(png.data()),
static_cast<std::streamsize>(png.size()));
}
const auto import_job =
session.commands()
.call("faset_import",
{{"path", "Assets/TwoPixels.png"}, {"settings", {{"pixels_per_unit", 1.0}}}})
.at("job")
.get<std::string>();
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<std::string>();
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");
+33 -1
View File
@@ -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")
+153
View File
@@ -0,0 +1,153 @@
#include <chrono>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/render/renderer.hpp>
#include <filesystem>
#include <iostream>
#include <thread>
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;
}
}
+23
View File
@@ -1,3 +1,4 @@
#include <SDL3/SDL.h>
#include <cmath>
#include <faset/render/render_graph.hpp>
#include <faset/render/renderer.hpp>
@@ -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) {
+180
View File
@@ -0,0 +1,180 @@
#include <SDL3/SDL.h>
#include <chrono>
#include <cstdlib>
#include <faset/render/renderer.hpp>
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <thread>
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<WindowEvents*>(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<unsigned>(pixel_width) &&
renderer.height() == static_cast<unsigned>(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<unsigned>(w) &&
renderer.height() == static_cast<unsigned>(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;
}
}
+102
View File
@@ -5,6 +5,7 @@
#include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp>
#include <iostream>
#include <numbers>
#include <stdexcept>
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<float> / 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");
+110
View File
@@ -0,0 +1,110 @@
#include "Gameplay.hpp"
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <set>
#include <stdexcept>
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<std::string>());
check(manifest.at("dimension") == FASET_EXAMPLE_DIMENSION,
"project dimension matches test");
std::set<std::string> ids;
for (const auto& entity : scene.at("entities")) {
check(ids.insert(entity.at("id").get<std::string>()).second, "unique entity IDs");
for (const auto& component : entity.at("components"))
check(ids.insert(component.at("id").get<std::string>()).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;
}
}
+28
View File
@@ -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;
+1 -1
View File
@@ -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.
+157
View File
@@ -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)
+121
View File
@@ -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()
+79
View File
@@ -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())
+221
View File
@@ -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())